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:
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
- The kernel: the database layer, and the Kernel view in the app where admins manage types and who may see and change them, and everyone can read the log.
- Built-in: the version of an app that ships with Core.
- Your version: your own copy of an app, changed by you or by the agent for you. Only you run it.
- Team version: a version an admin published; everyone without their own version runs it.
- Component: a piece of an app (a list, a card, a metric) that can be mounted on its own: as a Home widget, or inline in the chat.
- Via: where a write came from. Every log entry carries one.
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:
| Type | Stored as | Accepts |
|---|---|---|
text | string | Up to 2,000 characters. |
longtext | string (markdown allowed) | Up to 200,000 characters. |
number | number | Numbers, or strings like "2.5" or "2,5". A thousands-shaped "1,234" is ambiguous and refused. |
money | number of euros, 2 decimals | Also "€ 1,234.50" and "1.234,50"; rounded to cents. |
date | YYYY-MM-DD | A real calendar date; an ISO datetime is cut to its date. |
datetime | ISO 8601 with offset | With 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. |
select | one of options | Matched case-insensitively, stored with the option's spelling. |
bool | boolean | true/false, 1/0, yes/no, on/off. |
ref | record id | Must exist and be of the field's ref type. |
account | account id | Someone in the workspace (maya, sam, …). |
accounts | array of account ids, or null | An 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 }
- Ids are short, URL-safe and prefixed by type:
task_8f2k1q. _labelshas display strings forref,accountandaccountsfields, so you rarely need a second lookup. A ref to a record you can't read is labelled"Restricted", never with its title._titleis the title field's value, or “Untitled task”.- Keys starting with
_andidare ignored on write, so you can pass a whole record back as a patch. - Deleting a record that other records point to is refused (409), naming up to three of them.
- Invoice numbers are unique: a create or update that reuses one is refused (409).
- A patch that changes nothing writes nothing and logs nothing.
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.
- An admin may read and write every type.
- If a type's
readis null or empty, everyone may read it; otherwise only the listed roles (and admins). - The same for
write, with one more rule: writing needs reading. A role that can't read a type can't write it either.
| In the demo | read | write |
|---|---|---|
invoice | finance | finance |
deal | sales, finance | sales |
| everything else | everyone | everyone |
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
| Where | A type you can't read |
|---|---|
| In an app | Silent. list, watch, search and aggregate return nothing; get returns null. Nothing in the error band. |
GET /api/records?type=…, /api/aggregate | 403 with who can: Invoices are visible to finance and admins. |
GET · PATCH · DELETE /api/records/:id | 404, the same as a record that doesn't exist, so it doesn't confirm one does. |
| Creating one | 403. |
| A ref to one | Labelled "Restricted". Pointing a ref at a record you can't read reads as “nothing has that id”. |
| The log | Entries about its records are left out entirely, in /api/log and on the stream. |
| Deleting a record others point to | The 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 }
| Condition | Meaning |
|---|---|
{field: value} | Equals. On an accounts field: includes. |
eq · ne | Equals / doesn't equal. |
gt · gte · lt · lte | Compared as numbers, dates, datetimes (by instant) or text (natural order). Empty values never match. |
in | One of a list. On an array field: shares any value. |
contains | Case-insensitive substring; on an array field, includes. |
empty | true: 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 }] }
metric:count(default),sum,avg,min,max. All but count need anumberormoneyfield; sum and avg round to cents.groupBy: any field. Date and datetime fields (and_createdAt/_updatedAt) bucket with:day,:week(the Monday it starts on),:monthor:year. Anaccountsfield counts a record once per person. Empty values group as “None”.- Groups are sorted by value, largest first; time buckets chronologically.
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.kind | Shape | Shown 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):
- create → the record is deleted, if it hasn't changed since and nothing points to it.
- update → the changed fields get their old values back, if none of those fields changed again since. Other fields are left alone.
- delete → the record comes back with the same id.
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.
- Field keys are derived from the label in camelCase (“Close date” →
closeDate) unless you give one: a lowercase letter, then letters and digits. - A
selectneeds options; arefnames an existing type. - Fields are never removed or renamed. Options can be removed only when no record uses them.
- A new type with no fields gets a required
nametext field as its title.
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.
| Key | What it is |
|---|---|
name | Required. Shown in the rail and the app bar. |
glyph | One character for the rail icon. |
description | One line on what it's for. The agent reads it. |
reads | Record types the app reads. |
opens | Types 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. |
summary | For 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
| Call | Does |
|---|---|
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
| Property | Is |
|---|---|
await kernel.ready | Resolves 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.accounts | Everyone 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.props | What 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.timezone | The workspace's zone, e.g. "Europe/Amsterdam". |
Shell & helpers
| Call | Does |
|---|---|
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.fmt | money(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.
- Compact, live (use
watch) and interactive: writes are allowed. - Good from 320 to 720 px wide. The shell sizes the frame to your content, up to about 560 px tall; don't use
vh. - Built-in apps ship at least three: a list or board, a single-record card (
{id}), and a summary or metric.
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
| Type | h1 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 |
| Controls | button.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.
- Versions menu: your history; restore any of your versions, or go back to the team or built-in version (your history is kept).
- Publish to team (admin): your version becomes the team version; you then run the team version too.
- New app: a custom app is yours alone until an admin publishes it.
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
- Everything on screen comes from
kernel.watch; every change goes throughcreate / update / remove. - Catch write errors and show
e.messagenext to the control. - Escape every record value with
kernel.ui.escbefore it goes intoinnerHTML. - Loading (skeletons until the first watch fires), empty and error states. Keyboard reachable.
- Disable what the person's role can't do (
kernel.can(type, "write")). Whenkernel.can(type)is false, say who can see the type rather than “nothing yet”. - Dates: compare with
kernel.today(); show withkernel.fmt. kernel.setContextwhen the selection changes; handle deep links withkernel.onprops.- At least three components; one marked
"home": true. Check light, dark and 390 px.
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:
- Answer from the kernel: query, search, aggregate and read the log, live.
- Write records: logged
via: {kind: "agent", threadId}and undoable from a card in the chat, from the log, or by asking it to undo. - Change any app for you: it rewrites the current source as a new version of yours (
author_kind: "agent") and shows an app-change card with Open, Undo and a summary of the change. - Build new apps from a description.
- Bring up any component of any app live in the conversation, or write a one-off view (a small chart or table in the same sandbox and runtime).
- Navigate: open an app or a record for you.
- Add fields and change who may see and change a type, if you're an admin, logged the same way as from the Kernel view. Anyone else is told who 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.
| Tool | Does |
|---|---|
describe_kernel | Types, fields, counts and people. |
query_records · get_record | Query a type; one record with its backlinks. |
search · aggregate · read_log | Search everything; counts and sums; recent changes. |
create_record · update_record · delete_record | Writes, logged via the agent; they return the log id for undo. |
undo_change | Undo one earlier write by its log id, with the same rules as Undo. |
list_apps · read_app_source | Apps with their manifests and components, and your/team versions; an app's source. |
edit_app · create_app · revert_app | Rewrite an app as your version (with progress while it writes); build a new app; go back a version. |
show_component · show_view · open_app | Mount a component in the chat; render a one-off view; open an app or record. |
add_field | Admins only. |
set_look | Your 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/threads | Your 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/:id | Delete 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/stream | Follow 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/stop | Stop 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.
| Event | Data |
|---|---|
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 |
embed | One of the embeds below |
title | {threadId, title}: the thread got its automatic title |
done · error | {messageId} · {code, message} |
| Embed kind | Carries |
|---|---|
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/workspace | Public. {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/logout | 204. |
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/schemas | Every schema. |
POST/api/schemas | Admin. {label, type?, plural?, icon?, titleField?, read?, write?, fields?} → 201 and the schema. |
PATCH/api/schemas/:type/access | Admin. {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/fields | Admin. {label, type, key?, required?, options?, ref?, help?} → 201 and the schema. |
PATCH/api/schemas/:type/fields/:key | Admin. {options} for a select field; options still in use can't be removed. |
Apps & versions
GET/api/apps | The apps you can open, resolved for you: [{id, name, glyph, description, kind, prov, versionId, n, manifest, hasTeam, hasYours, owner, summary, updatedAt}]. |
GET/api/apps/:id | The same plus source. |
GET/api/apps/heads | Who 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/:vid | One 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/me | Drop your version; run the team or built-in one again. History is kept. |
POST/api/apps/:id/publish | Admin. Your version becomes the team version. |
POST/api/apps/:id/heads/org | Admin. {versionId}: point the team at an earlier team version. |
DELETE/api/apps/:id/heads/org | Admin. Drop the team version (a custom app goes back to its maker). |
DELETE/api/apps/:id | Delete a custom app: its maker, or an admin once it's published. |
Prefs
GET/api/prefs/:appId | Your UI settings for that app, or null. |
PUT/api/prefs/:appId | A 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.
| Event | Data |
|---|---|
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).
| Variable | Default | |
|---|---|---|
PORT | 3030 | HTTP port. |
HOST | all interfaces; 127.0.0.1 in production | Production listens on loopback, behind a reverse proxy. |
DB_PATH | ./data/core.db | Created and seeded with the demo workspace on first boot. |
CORE_PASSCODE | core | The one passcode for every persona. Change it. |
ANTHROPIC_API_KEY | none | The agent. Without it the chat says it can't answer. |
CHAT_MODEL | claude-sonnet-5-5 | Chat turns. |
CODE_MODEL | claude-opus-5-5 | Rewriting and building apps. |
APPS_DIR | packages/apps in the checkout | Where 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_URL | http://localhost:5180 | Reserved: nothing reads it yet. |
NODE_ENV | none | production 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_PORT | 5180, 3030 | Dev only: vite's port and where it proxies /api. With distinct PORT and DB_PATH you can run several copies side by side. |
Production
- Run one instance (SQLite has one writer); the repo has a pm2 config (
ecosystem.config.cjs). - Put nginx in front and don't buffer
/api/streamor/api/chat: both are event streams. The server also sendsX-Accel-Buffering: no. - Back up the database file; it's the only state there is. That's the point.
- Schema changes in later versions are additive and run on boot.