One entity, two apps
Two of the person's apps can write about the same thing without either one
copying the other. This page is how: which app a component lives in, what a
deploy reports when a component already has a home, what happens across spaces,
how a page reads a sibling app, and how graph_query composes one bundle out of
several stores.
The eid is the thing
An eid is a uuid the client mints, and it means the same entity in every store on the platform. Nothing registers it, nothing hands it out, nothing has to agree. A reading list saves the book; a lending app, on the same eid, saves who has it. They are one entity with two components, one per store — no copy, no sync, nothing to keep in step.
let piranesi = crypto.randomUUID()
graph_apply { app: 'reading-list', entities: [
{ entity: { eid: piranesi }, doc: { title: 'Piranesi' },
book: { pages: 245 } } ] }
graph_apply { app: 'lending', entities: [
{ entity: { eid: piranesi }, loan: { to: 'Maya' } } ] }The eid is the whole address. An app's store mints no number beside it — a
number is a store's own counter, and would say #3 in one app and #17 in
another for the same entity — so a thing has one name everywhere.
Which app a component lives in
A component lives with the app that declares it, so nothing has to be
negotiated. book is the reading list's component wherever it is written;
loan is the lending app's. A write is split up by component and each part is
sent to the app that owns that component.
The platform's shared components — doc, comment, task, image, archived
— belong to no app, so they go, in this order:
- the app the bundle names, where it sets
"$app": "<slug>"; - the app where that entity already has that component;
- the app whose own component is in the same bundle — a title beside a recipe is the recipe's title, which is what makes writing a new entity one call.
$app is set on the bundle rather than on the call, because the bundles in one
write may land in several apps at once — it is the bundle that goes to one app,
not the whole write. When none of the three decides it, the write is refused
rather than guessed:
which app should doc go in? say $app on the bundle — reading-list, lendingEvery part is checked in its own store before any of them commits, so a refusal
in one leaves the others unwritten. A $alias is minted once, when the write
arrives, so a bundle landing in two stores lands under one eid.
graph_apply { entities: [
{ entity: { eid: piranesi },
doc: { title: 'Piranesi (2020)' }, → reading-list, where it lives
loan: { to: 'Bo' } } ] } → lending, whose component it isOne component, one home
Declare book in a second app of the same space and nothing is planted twice.
The first app in the space to declare a component is its home; a later
manifest naming it is a use, not a second declaration. The deploy reports that:
book lives in reading-list; this app reads and writes it there
components: loanbook is missing from components: on purpose — the lending app homes only
loan. Its store never plants a book table, so asking that store for one is a
refusal, and the rows are all in one place:
graph_query { app: 'lending', filter: '.book!' }
→ unknown prop: .book
graph_query { app: 'reading-list', filter: '.book!' }
→ both books, however they were writtenA column the borrower adds grows the home's table, additively, and is then writable from either app:
lending/vocab.json: { "$defs": {
"book": { "properties": {
"title": { "type": "string" },
"isbn": { "type": "string" } } },
"loan": { "properties": {
"to": { "type": "string" } } } } }
→ added: book.isbnFrom an agent's tools and from an app's own commands, a borrowed component
just works: graph_apply { app: 'lending', entities: [{ book: … }] } lands in
the reading list's store, and a tools.json entry of the lending app may name
book in its apply or its query. From a page it does not: ./api/apply
and ./api/query are this app's own HTTP endpoints onto its own store, so a
page that writes a borrowed component gets unknown component: book. Reach the
home app by its address instead — store('/reading-list/api/'), below.
The one refusal: a shape conflict
Declaring a component another app homes is refused for exactly one reason — the same column with two types. The rows already written under the home's type are the record of what that column is, and no manifest may rewrite them.
vocab.json: book.pages is text in this app and number in reading-list,
where book lives — a column keeps the type its rows were written underThe whole manifest is read before anything is planted, so that deploy moves nothing at all: not the home's column, not this app's own components, not its files.
Across spaces, a component means what its space declares
Within one space a component has one home. Across spaces there is no home to share, so the same name may describe two different shapes — and the answer tells you which.
Where the shapes agree, the name means one component and the bundle is one. A column only one side declares agrees by construction, since a vocabulary only ever grows.
Where they disagree — note.body is text in one space and number in
another — the rows stay apart. The same eid comes back as two bundles, each
naming the space it is answering for:
graph_query { filter: '.note!' }
→ [ { kind: 'note', space: 'shelf', entity: {…}, note: { body: 'lovely' } },
{ kind: 'note', space: 'stall', entity: {…}, note: { body: 3 } } ]A write has no such option — one bundle cannot land in two meanings — so it asks you:
note means two things — shelf and stall declare it differently;
name the app this goes inAnd what is out of reach is simply absent. Another person's private app can hold a component on the very eid you are reading, and it never appears on your bundle: reach is the apps you may read, and nothing else.
Reading a sibling app from a page
client.js exports store(base), which is the same six functions pointed at an
address you name:
import { query, store } from './api/client.js'
let lending = store('/lending/api/')
let loans = await lending.query('.loan!&.doc?')Every app in a space shares one hostname, so the address is a path, not a URL —
/lending/api/. It resolves against the page's own origin, and the trailing
slash is optional (the endpoints are under it either way). Your own app is a
path like any other, so store('/reading/api/') is the same store as the bare
query you imported.
These are that app's own endpoints, so that app's access decides: a private
sibling answers its members only, whoever is asking, and a public one accepts
writes from a member and reads from anyone with the link. Nothing about being a
neighbour grants anything.
graph_query with no app named
Name an app and you get that app's own answer, untouched. Leave app out and
the question is asked of every app in reach — every app the person may read, in
every space they belong to — and answered as one bundle per entity.
graph_query { filter: '.book!&.loan?' }
→ [ { kind: 'book', entity: { eid: '…' },
book: { pages: 245 }, loan: { to: 'Maya' },
_stores: { book: 'yourname/reading-list', loan: 'yourname/lending' } } ]!names which entities the answer is about;?asks for a component beside them without filtering on it. So.book!&.loan?is every book, with its loan where it has one, and.book!&.loan!is only the books that are out —&is an intersection across apps exactly as within one._storesnames which app holds which component. It appears only on a bundle that actually spans two apps, which is where you need it: to write one component back, you need to know whose it is.kindis one of the app's own components, never a platform one — and when a row carries components from two apps, the one the filter required wins, so.book!&.loan?and.loan?&.book!both return books..count!counts entities, not rows: summing each store's own count would count a spanning entity twice.limit=bounds each part before the parts are combined, so a mixed filter's window is the newest of each side, then the newest of what they had in common..distinctand.tallyread one app at a time, and refuse when no app is named: name one withapp, or ask for the rows and reduce them yourself.- A component nobody in reach declared is a refusal, not an empty answer:
unknown prop: .sandwich. searchwith no app named merges every app's ranked hits, best first.
A pair, end to end
Two apps, one shelf of books.
reading-list/vocab.json
{ "$defs": {
"book": { "properties": {
"author": { "type": "string" },
"pages": { "type": "number" } } } } }
lending/vocab.json
{ "$defs": {
"book": { "properties": {
"author": { "type": "string" } } },
"loan": { "properties": {
"to": { "type": "string" },
"due": { "type": "string", "format": "date-time" } } } } }The lending deploy reports:
book lives in reading-list; this app reads and writes it there
components: loanNow one call writes both halves:
graph_apply { entities: [
{ entity: { eid: '$b' },
doc: { title: 'Solenoid' },
book: { author: 'Mircea Cărtărescu', pages: 672 },
loan: { to: 'Maya', due: '2026-10-01T00:00:00Z' } } ] }doc and book land in the reading list; loan lands in lending; the alias is
minted once, so both are the same entity. Each app's own page draws its own half
— the reading list's query('.book!&.doc?') never mentions loans — and either
page can borrow the other's view when it wants it:
let out = await store('/lending/api/').query('.loan!')
let due = new Map(out.map((r) => [r.entity.eid, r.loan.due]))
for (let b of await query('.book!&.doc?')) {
draw(b.doc.title, due.get(b.entity.eid))
}And the person's agent sees the whole thing at once, without naming an app:
graph_query { filter: '.book!&.loan!&.doc?' }
→ every book that is out, with its title and who has itNeither app knows the other's schema. Delete the lending app and the books are untouched; delete a book and its loan goes with the entity.
The whole guide is at The guide; the components themselves, and
vocab.json, are Components: the platform's, and your own, and the filter grammar
in full is Querying: the filter grammar.
