Documentation

How Core works

Your people are already building their own software; Core gives them somewhere it's safe. It is one database for the whole team, a complete set of apps on top that is good to work in from day one and that anyone can make their own by asking, and an agent that is the main way in. The apps can't store or send data, so there is one copy of it. This page covers all of it, from the kernel's rules to the API.

01Concepts

Core has three layers, and the colours in the product always mean the same thing:

KernelOne database: types, records, permissions and an audit log. The only place data lives. Shared by everyone.
AppsSmall, frontend-only apps (Mail, Calendar, Tasks, Docs, Files, Clients, Projects, Invoices, Data), each in a locked-down sandbox that can only read and write the kernel. Anyone can rewrite any app for themselves.
AgentThe chat. It queries the kernel, writes records, rewrites apps for you, builds new ones, and brings up any app's components inline in the conversation.

The rule: apps never store data. No network, no browser storage, no backend of their own. Everything goes through the kernel, so there is exactly one copy of the data and every write is attributable: who, from which app and version (or the agent), before → after.

Words you'll see

02Run it locally

Core is a TypeScript monorepo (npm workspaces, ESM). One Node process serves the API and the web.

git clone <your fork> core && cd core
npm ci
npm run dev       # API on :3030 (tsx watch) + vite on :5180, proxied

Open http://localhost:5180/app and sign in as any of the six people in the demo workspace with the passcode (core unless you set CORE_PASSCODE). The database is created and seeded on first boot; delete the file to start over. The demo is Halden Studio, a six-person design studio in Amsterdam, with dates relative to the day it was seeded.

npm run build     # server tsc + web vite → packages/server/public
npm run start     # the built server (seeds data/core.db on first boot)
npm run verify    # end-to-end checks on a scratch database

03Types & fields

The kernel holds records of types. A type (its schema) has a key, a label and plural, a title field, an icon, the roles that may read it and write it, and its fields. The demo workspace ships ten: company, contact, deal, project, task, invoice, doc, message, event and file.

// Schema
{ type: "task", label: "Task", plural: "Tasks", titleField: "title", icon: "✓",
  read:  null,                 // or ["finance"]: who may read besides admins
  write: null,                 // or ["finance"]: who may write besides admins
  fields: [{ key, label, type, required?, options?, ref?, help? }], sort }

Every write is validated and coerced against the fields. There are eleven field types:

TypeStored asAccepts
textstringUp to 2,000 characters.
longtextstring (markdown allowed)Up to 200,000 characters.
numbernumberNumbers, or strings like "2.5" or "2,5". A thousands-shaped "1,234" is ambiguous and refused.
moneynumber of euros, 2 decimalsAlso "€ 1,234.50" and "1.234,50"; rounded to cents.
dateYYYY-MM-DDA real calendar date; an ISO datetime is cut to its date.
datetimeISO 8601 with offsetWith an offset, or without one (then it's wall-clock time in the workspace's time zone). Stored with the workspace zone's offset, e.g. 09:30:00+01:00.
selectone of optionsMatched case-insensitively, stored with the option's spelling.
boolbooleantrue/false, 1/0, yes/no, on/off.
refrecord idMust exist and be of the field's ref type.
accountaccount idSomeone in the workspace (maya, sam, …).
accountsarray of account ids, or nullAn array or a comma-separated string; duplicates removed.

Empty values ("", [], null) are stored as null. Required fields must be set on create, and a patch can't clear them. A field that isn't in the schema is refused, never added:

400 “estimate” is not a field of Task. Apps can't add fields; an admin adds them in the kernel.

Error messages are sentences meant for people: show them next to the control that caused them.

04Records

Apps, the API and the agent all see the same shape: fields flat, metadata under _.

{ id: "task_juzxwj",
  title: "Design the vehicle add-on step", status: "doing", assignee: "lotte",
  project: "project_asa3fq", due: "2026-10-28", …every field, null when empty
  _type: "task", _title: "Design the vehicle add-on step",
  _labels: { assignee: "Lotte Bakker", project: "Waddenveer booking app" },
  _createdAt, _createdBy, _updatedAt, _updatedBy }

05Permissions

There are four roles: admin, finance, sales and member. Access is set per type and per role: each type says who may read it and who may write it.

In the demoreadwrite
invoicefinancefinance
dealsales, financesales
everything elseeveryoneeveryone

So Maya (admin) and Sam (finance) see and change invoices; Maya and Priya (sales) change deals and Sam sees them; Lotte, Daan and Ruben (members) see neither.

The kernel checks this on every read and every write, whichever app, version or agent makes the call, and on every path a record could leak through: the live copy apps read from, the stream, search, aggregates, backlinks, ref labels and the log. Rules for single records or single fields aren't built yet, and the four roles are fixed.

What a restricted read looks like

WhereA type you can't read
In an appSilent. list, watch, search and aggregate return nothing; get returns null. Nothing in the error band.
GET /api/records?type=…, /api/aggregate403 with who can: Invoices are visible to finance and admins.
GET · PATCH · DELETE /api/records/:id404, the same as a record that doesn't exist, so it doesn't confirm one does.
Creating one403.
A ref to oneLabelled "Restricted". Pointing a ref at a record you can't read reads as “nothing has that id”.
The logEntries about its records are left out entirely, in /api/log and on the stream.
Deleting a record others point toThe refusal names and counts only records you can read, then says “and others you can't see”, never how many.

The schemas themselves stay visible to everyone, with their read and write, so an app can say who may see a type instead of showing an empty list as if there were nothing. Check with kernel.can(type) (read) or kernel.can(type, "write"):

if (!kernel.can("invoice")) {
  el.innerHTML = `<p class="empty">Invoices are visible to finance and admins.</p>`;
}

Apps should disable what the person can't change, but they don't have to: the kernel says no either way.

403 Only sales and admins can change deals. You're signed in as Sam Okafor (finance).

Who decides

Admins do, in the Kernel view, through the API (PATCH /api/schemas/:type/access) or by asking the agent. A change takes effect everywhere at once: every open shell refetches its live copy, so apps gain or lose records on screen without a reload. It's logged as a kernel change by that admin (“Maya let finance read Invoices”) and can be undone from the log. Anyone else gets a sentence naming the admins.

Changing types and fields, publishing team versions and undoing someone else's change are admin-only too.

06Query, search, aggregate

The same query semantics run on the server, in every app's sandbox and for the agent, so a watch and a REST call always agree.

Query

{ where:  { status: "todo", due: { lt: "2026-10-26" }, assignee: { in: ["daan", "lotte"] } },
  sort:   "-priority,due",   // "-" = descending; comma for ties
  limit:  20, offset: 0 }
ConditionMeaning
{field: value}Equals. On an accounts field: includes.
eq · neEquals / doesn't equal.
gt · gte · lt · lteCompared as numbers, dates, datetimes (by instant) or text (natural order). Empty values never match.
inOne of a list. On an array field: shares any value.
containsCase-insensitive substring; on an array field, includes.
emptytrue: null, "" or []. false: has a value.

Several conditions are ANDed. Sort also takes _title (the default), _createdAt and _updatedAt; empty values sort last either way. A where or sort on a field that doesn't exist is an error that lists the type's fields.

Search

Case-insensitive substring over every text and longtext field of every type. Title hits rank first (exact, then prefix, then anywhere), then the most recently updated. A record also matches when every word of the query appears in its text, the names of the records and people it links to, or its type name (“Kunsthal invoice” finds the Kunsthal invoice); those rank after direct text hits, records of the named type first. 20 results by default, at most 100.

Aggregate

{ type: "invoice", metric: "sum", field: "amount",
  groupBy: "issued:month", where: { status: "paid" } }
→ { metric, field, groupBy, value, count, groups: [{ key, label, value, count }] }

07The log

Every write is logged with the full record before and after, a title, who made it and via what:

{ id: "log_…", at, accountId: "sam", op: "update", type: "task", recordId: "task_…",
  title: "Send the milestone 2 invoice to Waddenveer",
  before: { …full data }, after: { …full data },
  via: { kind: "agent", threadId: "thread_…" },
  revertedBy: null, reverts: null }
via.kindShapeShown as
app{appId, appName, versionId, prov: "builtin" | "team" | "yours" | "draft", threadId?}The app and version, with a ring in the author's colour
agent{threadId}“Core agent”, in sulphur
kernel{}The Kernel view or an admin action, in basalt (no hue)
seed{}The demo data

App code never sets via. The shell knows which sandbox made a call and attaches it; the server then trusts only the appId and versionId, checks that the version is the person's own or the team's, and fills in the name and provenance itself. A draft is an agent-made view in the chat: it has no saved version and may name the chat thread it came from.

Ops are create, update, delete, revert, schema (types, fields and who may read and write a type) and app (versions saved, published, switched).

An access change is a schema entry with type set and the full schema before and after; its title reads as a sentence after the admin's name:

{ op: "schema", accountId: "maya", type: "invoice", recordId: null,
  title: "Let finance read Invoices",  // shown as “Maya let finance read Invoices”
  before: { …schema, read: ["finance", "member"] },
  after:  { …schema, read: ["finance"] },
  via: { kind: "kernel" } }

You only see entries you're allowed to: entries about records of a type you can't read are left out, from /api/log, kernel.log and the stream alike. Schema and app entries are visible to everyone.

08Undo

Any record write can be undone from the log, by the person who made it or by an admin (who must also be allowed to write the type):

Restored values must still pass today's schema (an option that was removed, a ref that was deleted); otherwise the undo is refused with the reason. An undo is itself logged with op: "revert" and reverts pointing at the original, which gets revertedBy. An undo can't be undone; make the change again instead. App versions are restored from the app's Versions menu, not the log.

An access change can be undone too, by an admin: who may read and write the type goes back to what it was, logged as a revert with recordId: null. It's refused (409) if access to that type changed again since; undo the later change first. Undoing an entry about a record you can't read is a 404.

09Changing the schema

Admins own the schema: they add types, add fields, edit a select field's options and decide who may read and write each type, in the Kernel view or through the API. Each change is logged with op: "schema" and pushed live to every open app, so a new field shows up without a reload.

10Anatomy of an app

An app is one HTML fragment: a manifest, markup, a <style> and one or more <script>s. Core places it in the <body> of a sandboxed iframe, after base.css and the runtime that defines the global kernel. Built-ins live at packages/apps/<id>/app.html; tasks is the reference to copy.

<!-- manifest first -->
<script type="application/json" id="core-manifest">
{ "name": "Follow-ups", "glyph": "↻", "description": "Tasks that came out of client mail.",
  "reads": ["task", "company"], "opens": [],
  "components": {
    "due": { "title": "Follow-ups due", "description": "Open follow-ups, soonest first.",
             "props": { "company": "company id, optional" }, "home": true } } }
</script>

<div class="toolbar"><h1>Follow-ups</h1><span class="spacer"></span></div>
<div id="rows"><div class="skeleton"></div></div>

<script>
  const { esc } = kernel.ui;
  function draw(el, tasks) {
    el.innerHTML = tasks.length
      ? `<ul class="list">${tasks.map((t) => `<li>${esc(t.title)} <span class="pill">${kernel.fmt.relday(t.due)}</span></li>`).join("")}</ul>`
      : `<div class="empty"><strong>Nothing to follow up</strong></div>`;
  }
  const open = (company) => ({ where: { status: { ne: "done" }, ...(company && { company }) }, sort: "due" });

  // components register at the top level, synchronously
  kernel.component("due", (el, props) => kernel.watch("task", (rows) => draw(el, rows), open(props.company)));

  // full-screen init only in app mode
  if (kernel.mode === "app") kernel.watch("task", (rows) => draw(document.getElementById("rows"), rows), open());
</script>

11The manifest

A <script type="application/json" id="core-manifest"> at the top. Saving an app checks that it parses, has a name, and that the source has at least one real script; sources are capped at 512 KB.

KeyWhat it is
nameRequired. Shown in the rail and the app bar.
glyphOne character for the rail icon.
descriptionOne line on what it's for. The agent reads it.
readsRecord types the app reads.
opensTypes this app is the home for. kernel.link(id) and record links anywhere in Core open a record in the first app (in rail order) that opens its type, with props {id}. ["*"] means any type, at lowest priority (the Data app).
components{name: {title, description?, props?, home?}}. props maps each prop to a description (“task id, required”); home: true suggests it as a Home widget.
summaryFor a personal version: one line on what this version changes.

12Runtime API

The runtime defines a global kernel before your scripts run. Reads are answered from the shell's live copy of the kernel, so they're instant; writes go to the server with your app's provenance attached. Calls return promises that reject with an Error whose message is a sentence for people.

Data

CallDoes
kernel.watch(type, fn, query?)Calls fn(records) now and again after every change by anyone, only when the result changed. Returns unwatch(). Use it for everything on screen.
kernel.list(type, query?)→ records matching a query.
kernel.get(id)→ one record, or null (also for a record you can't read).
kernel.search(q, limit?)→ records across all types.
kernel.aggregate(spec)→ an aggregate result.
kernel.create(type, data)→ the new record.
kernel.update(id, patch)→ the updated record. Send only the fields you change.
kernel.remove(id)Deletes. Undoable from the log, so an inline “click again to delete” is enough.
kernel.log({limit, type, recordId})→ recent log entries, newest first.
kernel.revert(logId)Undoes one logged create, update or delete (as the log's Undo does) → {entry, record}. Refused with a sentence if the record changed since.

Who, where, when

PropertyIs
await kernel.readyResolves once the shell has connected. Calls made earlier wait for it.
kernel.me{id, name, email, role, title, hue} of the person using the app.
kernel.accountsEveryone in the workspace.
kernel.schemas{type: schema}, kept live when an admin changes them. Includes types you can't read, with their read and write.
kernel.can(type, what?)May I "read" (the default) or "write" records of this type? Admins always may; writing needs reading. Check it before showing an empty state: reads of a type you can't read are silent. See Permissions.
kernel.mode"app" (full screen) or "inline" (a component somewhere else).
kernel.propsWhat the app or component was opened with, e.g. {id}. {} if nothing.
kernel.onprops(fn)App mode: called with new props when the shell re-opens the mounted app (a record link). Without a handler, the shell reloads the app with the new props.
kernel.today()Today as YYYY-MM-DD in the workspace's time zone, the same day the server and the agent use. Compare date fields with it.
kernel.timezoneThe workspace's zone, e.g. "Europe/Amsterdam".

Shell & helpers

CallDoes
kernel.prefs.get() / .set(obj)This app's UI settings for this person (a filter, a sort), at most 4 KB. Never data, never derived values.
kernel.toast(text, opts?)A short message in the shell. With {action: "Open", link: id} (or {action, app, props}) it has a button that opens that record (or app).
kernel.open(appId, props?)Opens another app.
kernel.link(id)Opens a record in its home app.
kernel.ask(text) · kernel.setContext(obj)See Talking to the agent.
kernel.component(name, render)See Components.
kernel.fmtmoney(n, digits?) €1,250.00 · number(n) · date(d) 27 Oct · datetime(iso) Tue 27 Oct, 09:30 · time(iso) · relday(d) today, tomorrow, in 3 days · ago(iso) 5 min ago · account(id) · initials(name)
kernel.ui.esc(s)Escapes text for innerHTML. Use it for every value that came from a record.
kernel.ui.avatar(id, size?)A round avatar in the person's colour, as an HTML string.
kernel.ui.person(id)A person's colours as CSS values that follow light and dark: {fill, soft, ink, wash, on}. Also on the page as --p-<id>, --p-<id>-soft, -ink, -wash.
kernel.ui.icon(name, {size?, person?, glyph?, tile?, label?})One of Core's icons as an SVG string: a built-in app id, or home, kernel, agent, new-app, core. With person (an account id, or true for you) its core part takes that person's colour.

Uncaught errors (and errors from a watch with a bad query) show in the shell's error band with “Ask Core to fix”. The runtime also keeps the theme in sync and passes ⌘K / Ctrl-K through to the shell.

13Components & inline mode

Components are how a piece of one app shows up somewhere else: on Home, or in the chat when the agent brings it up. Register them at the top level of your script, synchronously (never after an await):

kernel.component("task", (el, props) => {
  return kernel.watch("task", ([t]) => {
    el.innerHTML = t ? `<div class="card">${kernel.ui.esc(t.title)}</div>` : `<div class="empty">This task was deleted.</div>`;
  }, { where: { id: props.id } });   // return the cleanup: the unwatch
});

In inline mode the runtime hides the app's own markup, creates #core-inline and calls render(el, props) once your scripts have run. New props call your cleanup and render again. Guard full-screen init with if (kernel.mode === "app") so it doesn't run inline.

14Talking to the agent

The chat knows which app you have open, its version and its props. Tell it what the person is looking at, so “change this task” just works:

kernel.setContext({ selected: "task_8f2k1q", filter: "mine" });   // app mode, ≤ 2 KB, replaces; null clears

kernel.ask(text) hands text to the agent, as the app (the chat labels it with your app's name). It sends by itself from a full-screen app or within a few seconds of a click; otherwise it fills the composer for the person to send. At most once every 5 seconds per app; call it from a click.

15The sandbox

Every app runs in:

<iframe sandbox="allow-scripts allow-forms" srcdoc="…">
<meta http-equiv="Content-Security-Policy" content="
  default-src 'none'; script-src 'unsafe-inline';
  style-src 'unsafe-inline' <origin>; font-src <origin>;
  img-src data: blob:; connect-src 'none'; form-action 'none'">

No network (no fetch, no WebSocket, no external images), no storage (an opaque origin has no localStorage, IndexedDB or cookies), no popups or new tabs, no alert/confirm/prompt. Core's fonts (Atkinson Hyperlegible Next and Mono, and Anybody for display) are loaded for you. This is what makes “apps never store data” true rather than a guideline: an app physically can't keep its own copy.

The way out is a MessagePort. The document says hello once with a single-use nonce the shell baked into it and gets a port back; everything travels on that port. A frame that navigates away leaves its port behind and is cut off with an error, so never navigate the frame yourself (location.href, reload, links with href): use kernel.open and kernel.link.

16base.css

Loaded into every sandbox so apps look native with little CSS of their own. Light and dark come from prefers-color-scheme and the shell's data-theme; use tokens, never raw colours.

Tokens

Surfaces--bg --surface --sunk --raised
Text--ink --muted --faint
Lines--line --line-strong
Shared (basalt, no hue)--kernel --kernel-soft --kernel-ink --on-kernel, aliased as --accent --accent-soft --accent-ink; --basalt-band --chalk --bedrock-pattern for a slab that stays dark in both modes
Yours (the viewer's own colour)--yours --yours-soft --yours-ink --yours-wash --on-yours; anyone's as --p-<id> (+ -soft -ink -wash)
Agent (sulphur, a fill only)--agent --agent-soft --agent-ink, and --on-agent for text on it
States--ok --ok-soft --warn --warn-soft --bad --bad-soft
Shape & type--radius --radius-sm --shadow --shadow-lg --font-ui --font-mono --font-display

Primitives

Typeh1 h2 h3 (h3 is a section label) .muted .faint .mono .small .num
Layout.toolbar .spacer .row .stack .grid .scroll .card
Data.table (tr.click, td.num) .list .metrics .metric (.label .value small, data-tone, or a button.metric)
Labels.pill[data-tone=ok|warn|bad|accent|kernel|yours|agent] .avatar .kbd
Controlsbutton.primary .ghost .danger .sm .icon .link, .seg (buttons with aria-pressed), label.check, styled inputs, selects and a themed checkbox
States.empty (with a strong line), .skeleton, .error
Whose.yours (one wash in your colour per screen), .agent-edited with <span class="agent-tag">Core · just now</span> on a record the agent changed, .landed (the one pulse when a write lands)

The iframe's width is your app's width: use @media for narrow layouts (phones, or desktop with the chat dock open).

A personal look

Each person can have their own look: their own values for these tokens, in light and dark, set by asking Core ("I love orange, make it rounder") or with PUT /api/me/look. Core adds it after base.css in their shell and in every app, component and view they run, live, and on nobody else's screen. A look may set the surfaces, text, lines, --kernel/--accent and --yours families, states, radius, fonts, shadows and --lip-color; never the agent's sulphur or people's --p-* colours. So: use tokens, and never redefine them in your app (:root { --kernel: … }) unless the app is meant to have colours of its own.

17Versions

Anyone can save their own version of any app: by hand in the Source drawer, or by asking the agent. Each save is a numbered version with a summary and an author (human or agent), and becomes what you run. What someone runs resolves yours → team → built-in.

Shipping a personal version with Core: put it at packages/apps/<id>/variants/<accountId>.html with a "summary" in its manifest. It's loaded as that person's v1 when a database is first seeded.

18Checklist for a good app

19The agent

Chat is the primary interface. On Home it's the big composer (“Ask anything, or describe a change”) with your threads; with an app open it sits in a dock on the right (⌘K / Ctrl-K focuses it; a full-screen sheet on phones). The open app, its version, its props and its setContext state go with every message.

It runs on the server as the signed-in person, with their permissions: it can't read a type they can't, and nothing from one goes into its context. It can:

When it rewrites an app it follows the same rules as everyone: the kernel is the only source of truth, prefs hold UI settings only, it can't add fields (it names the missing field instead), it keeps existing components and a valid manifest, and it writes code a person could read and edit.

Budget: 60 agent turns per session per hour, and a shared cap for the public demo. Without ANTHROPIC_API_KEY the chat says it can't answer; everything else works.

20Tools

Every tool runs as the person, through the same kernel functions as the REST API. Tool errors go back to the model as sentences, so it can correct itself.

ToolDoes
describe_kernelTypes, fields, counts and people.
query_records · get_recordQuery a type; one record with its backlinks.
search · aggregate · read_logSearch everything; counts and sums; recent changes.
create_record · update_record · delete_recordWrites, logged via the agent; they return the log id for undo.
undo_changeUndo one earlier write by its log id, with the same rules as Undo.
list_apps · read_app_sourceApps with their manifests and components, and your/team versions; an app's source.
edit_app · create_app · revert_appRewrite an app as your version (with progress while it writes); build a new app; go back a version.
show_component · show_view · open_appMount a component in the chat; render a one-off view; open an app or record.
add_fieldAdmins only.
set_lookYour look for everything at once, light and dark; the card shows the colours and has Undo. Colours across apps are always this, never a rewrite per app.

21Chat API & stream

GET/api/chat/status{ready, reason, model, codeModel}: whether the agent is connected (an API key) and which models it uses.
GET/api/chat/threadsYour conversations, most recent first: [{id, title, createdAt, updatedAt, preview, question, busy?}]. question is the first thing you asked; busy: true while Core is still answering.
POST/api/chat/threads{title?}: a new conversation.
GET/api/chat/threads/:id{thread, messages, busy}.
PATCH/api/chat/threads/:id{title}: rename.
DELETE/api/chat/threads/:idDelete it with its messages.
POST/api/chat/threads/:id/messages{text, context, from?}, or {retry: true}: one turn, answered as server-sent events. 503 without an API key; 429 over the budget; 409 while a turn is running, or on a retry of an answer that changed things.
GET/api/chat/threads/:id/streamFollow a turn that is still running: replays its events so far, sends caught_up, then follows live. 204 when nothing is running. A turn keeps going if the page closes.
POST/api/chat/threads/:id/stopStop the running turn → 204. What was said so far is kept.

context is where the person is: {view: "home" | "app" | "kernel", app?: {id, name, versionId, n, prov, props, state}}. from marks text an app handed over with kernel.ask. Messages are saved as blocks (text, tool summaries, embeds, errors), so a reloaded thread re-renders its embeds live.

EventData
user{message}: the saved user message
text{delta}: markdown, streamed
tool_start · tool_end{id, name, label} · {id, ok, label, detail?}, e.g. “Queried 14 invoices”
progress{id, label}: a running tool's label changed, e.g. while an app is being rewritten
embedOne of the embeds below
title{threadId, title}: the thread got its automatic title
done · error{messageId} · {code, message}
Embed kindCarries
component{appId, component, props, title?}: a live component, with “Open in {App}”
view{title, html}: an agent-written fragment in the same sandbox and runtime; its writes are logged as a draft
app_change{op: "edit" | "create" | "revert", appId, appName, glyph, versionId, n, summary, prev}: Open, Undo (back to prev) and the summary
record_change{op, logId, recordType, recordId, title, before, after}: Undo is POST /api/log/:logId/revert
navigate{target: {kind: "app", appId, props?} | {kind: "record", id}, label}

22REST API

JSON over HTTP under /api. The shell, the agent and your scripts all use the same kernel functions, with the same validation, permissions and log.

Auth

GET/api/auth/workspacePublic. {workspace, accounts} for the sign-in screen.
POST/api/auth/login{accountId, passcode} → {me} and an httpOnly core_session cookie (30 days). Ten wrong passcodes in ten minutes from one address → 429.
POST/api/auth/switch{accountId} → {me}. Become someone else without the passcode (it's a demo: “view as Sam” is the point).
POST/api/auth/logout204.

Everything else needs the session cookie and answers 401 {"error": "Sign in first."} without one.

Errors

{"error": "A sentence."} with a 4xx status: 400 invalid, 401 signed out, 403 not allowed, 404 not found, 409 conflict (references, a duplicate invoice number, undo), 413 too big, 429 rate limited.

Where writes come from

Writes take an optional via: {kind: "kernel"} (the default) or {kind: "app", appId, versionId, prov}, where versionId must be your own or the team's version of that app (null for the built-in). Agent and seed writes never come through REST.

Bootstrap

GET/api/bootstrap{me, workspace: {name, timezone, createdAt}, accounts, schemas, apps, records, log}: every record you can read, every schema, and the last 100 log entries you may see. The shell keeps this live with the stream, and refetches it when access changes or you switch person.

Records

GET/api/records?type=task&where={json}&sort=-due&limit=20&offset=0 → records. X-Total-Count has the total before paging. 403 for a type you can't read.
GET/api/records/:id{record, backlinks}: the records you can read whose ref fields point here. 404 for a record you can't read.
POST/api/records{type, data, via?} → 201 and the record.
PATCH/api/records/:id{patch, via?} → the record.
DELETE/api/records/:id?via={json} → 204. 409 when other records point to it.
GET/api/search?q=waddenveer&limit=20 → records you can read.
GET/api/aggregate?type=&metric=&field=&groupBy=&where={json}, or ?spec={json} → the result.
curl -s -b cookies.txt 'localhost:3030/api/records?type=invoice&where={"status":"sent"}&sort=due'
curl -s -b cookies.txt -X PATCH localhost:3030/api/records/task_juzxwj \
  -H 'content-type: application/json' -d '{"patch": {"status": "done"}}'

Log

GET/api/log?limit=100&type=&recordId=&accountId=&before= → entries, newest first. limit up to 500; before is a log id (exact paging) or an ISO time.
POST/api/log/:id/revert{via?} → {entry, record}. See Undo.

Schemas

GET/api/schemasEvery schema.
POST/api/schemasAdmin. {label, type?, plural?, icon?, titleField?, read?, write?, fields?} → 201 and the schema.
PATCH/api/schemas/:type/accessAdmin. {read?, write?}: lists of roles, or null for everyone; omitted means unchanged. Roles are checked, deduplicated and kept in role order. → the schema. Logged (a change that changes nothing isn't) and undoable; see Who decides.
POST/api/schemas/:type/fieldsAdmin. {label, type, key?, required?, options?, ref?, help?} → 201 and the schema.
PATCH/api/schemas/:type/fields/:keyAdmin. {options} for a select field; options still in use can't be removed.

Apps & versions

GET/api/appsThe apps you can open, resolved for you: [{id, name, glyph, description, kind, prov, versionId, n, manifest, hasTeam, hasYours, owner, summary, updatedAt}].
GET/api/apps/:idThe same plus source.
GET/api/apps/headsWho runs which version of what.
GET/api/apps/:id/versions?owner=me|org → [{id, n, name, summary, author_kind, created_by, created_at, parent_id}], newest first.
GET/api/apps/:id/versions/:vidOne version with its source.
POST/api/apps{source, name?, glyph?, summary?} → 201, a new custom app owned by you.
POST/api/apps/:id/versions{source, summary?, name?, glyph?, author_kind?} → 201, the new head of your version.
POST/api/apps/:id/heads/me{versionId}: switch to one of your versions (undo, redo, restore).
DELETE/api/apps/:id/heads/meDrop your version; run the team or built-in one again. History is kept.
POST/api/apps/:id/publishAdmin. Your version becomes the team version.
POST/api/apps/:id/heads/orgAdmin. {versionId}: point the team at an earlier team version.
DELETE/api/apps/:id/heads/orgAdmin. Drop the team version (a custom app goes back to its maker).
DELETE/api/apps/:idDelete a custom app: its maker, or an admin once it's published.

Prefs

GET/api/prefs/:appIdYour UI settings for that app, or null.
PUT/api/prefs/:appIdA JSON object, at most 4 KB (413 beyond: data belongs in the kernel).

Your look

PUT/api/me/look{light: {"--kernel": "#c2410c"}, dark: {…}} replaces your look; null resets it. A 400 names a token or value a look can't use; at most 4 KB. Not logged (a UI setting). The bootstrap has it as me.look.

Live stream

GET /api/stream is a server-sent event stream; the shell keeps one open and pushes changes into every sandbox's watches, which is how a write by the agent shows up in an open app the moment it happens. A comment heartbeat goes out every 25 seconds. Each event is built for whoever the session is signed in as when it's sent: record and log events about a type you can't read are dropped, and ref labels are redacted. Switching person or signing out ends the session's streams; the shell reconnects and refetches the bootstrap.

EventData
record{op: "create" | "update" | "delete", type, id, record?, logEntry}
schema{schemas} after any schema change, including access. When read access changed, the shell refetches its records.
app{appId, owner}: a version or head changed (owner is "org" or an account id)
log{entry} for schema and app operations
look{look}: your look changed (sent only to your own sessions)

23Self-hosting

Core is one Node process and one SQLite file (WAL mode). Built, it serves the landing page at /, these docs at /docs, the app at /app and the API at /api.

npm ci && npm run build
PORT=3030 DB_PATH=/var/lib/core/core.db CORE_PASSCODE='…' ANTHROPIC_API_KEY='…' \
  NODE_ENV=production node packages/server/dist/index.js

Configuration

Set in the environment or a .env file in the working directory (the environment wins).

VariableDefault
PORT3030HTTP port.
HOSTall interfaces; 127.0.0.1 in productionProduction listens on loopback, behind a reverse proxy.
DB_PATH./data/core.dbCreated and seeded with the demo workspace on first boot.
CORE_PASSCODEcoreThe one passcode for every persona. Change it.
ANTHROPIC_API_KEYnoneThe agent. Without it the chat says it can't answer.
CHAT_MODELclaude-sonnet-5-5Chat turns.
CODE_MODELclaude-opus-5-5Rewriting and building apps.
APPS_DIRpackages/apps in the checkoutWhere the built-in apps (and their seeded variants/) are read from. The built server reads them from disk, so deploy the whole checkout, not just dist/.
BASE_URLhttp://localhost:5180Reserved: nothing reads it yet.
NODE_ENVnoneproduction caches built-in app sources; otherwise they're re-read from disk on every request, so you can edit an app and reload.
VITE_PORT, API_PORT5180, 3030Dev only: vite's port and where it proxies /api. With distinct PORT and DB_PATH you can run several copies side by side.

Production

Open the demo workspace →