Two ways in
The CLI wraps the API and holds the credential, so most tools never see a token. The API is the same surface without the wrapper, and it is the front door for any AI that connects directly.
The route reference is the OpenAPI spec the API serves at /api/json, readable at /api. Both are relative to the API base, not the host — on prod the base is /api/lattris, so the spec is at https://lattris.com/api/lattris/api/json. It is the reference, not a promise of completeness: some routes are still missing their documentation. It is honest about credentials: it requires a bearer credential on every route site-wide and marks the open routes with an empty security list. Read it for every route's shape and its credential, and read this block for what the spec cannot carry: which door to use when, and why.
The CLI
lattris loginsigns in through the browser.lattris whoamiprints who the session acts as, and quietly refreshes an expired token.lattris logoutdrops the local session. A sign-out with no tokens in hand ends on the platform's site, since the identity service keeps no per-app landing without a token hint.lattris use <actor>binds a directory to an identity, so different workspaces act as different people on one machine.lattris capture memorysaves a note from stdin.--ai-summarygives it its abstract in the same call, and--linked-from <id>or--links-to <id>wires one edge as it is created — the existing block pointing at the new one, or the new one pointing out.lattris capture sessionsaves a whole conversation from its transcript, a main session's or a subagent's own, and says which shape it read.lattris capture recordingkeeps an audio recording, its source, or its transcript — a vendor transcript is converted here, because the door meets one shape.lattris link <from> <to> --verb --reasonwrites an edge,lattris unlink <from> <to> --verbremoves it, andlattris update edge <from> --verb --to --reasonrewrites the reason.--reasonis required and the CLI never invents one. A note references what it came from, a recording or a session, and a recording or a session leads to the note it produced; a session does not point outward at its sources, since the note is where an evening's sources gather.lattris update recording transcript <id> <file>replaces the words;lattris update recording speakers <id> <key>=me|<contact-id>|clearnames who spoke.lattris contact create "<name>" [--ai]records a person, or an AI role with--ai.lattris contact listlists yours,lattris contact get <id>reads one by its full id or a unique prefix, andlattris contact update <id> [--name] [--ai | --person] [--root <block-id>]renames it, changes its kind, or points it at a block of yours, an AI role at its page. A contact is yours alone: another owner's contact answers not found.lattris set banned-wordsshows this account's list, and replaces the whole list when given words.lattris set root <id>sets your root block, refused for a block you do not own, andwhoamishows it.lattris close question <id> --close-as updated|declined|already-answeredcloses an ask.--blockis required for updated and already-answered, and--notefor declined.lattris get memorieslists.lattris get memory <id>reads one back with its links, and--depth <n>walks on from there — one call can pull most of a tree that way, which the reading rules forbid, so raise it only when you mean to. Every read of a block carriesfingerprint,revisionNoandstructureHash, a hash over its current links and grants, and every edge carries ahash. For a note,--revision <n>reads one past revision.lattris update memory <id>corrects one. The new words arrive on stdin, the way capture takes them, and the banned-word gate fires on them with--allow-banned-wordsas the override. Three flags ride with it:--titleretitles--intent <name>re-declares what it is--clear-intenttakes it back to none--contributor <id>names whose expression the block is, the same flag capture takes
For a note,
--base-fingerprint <fingerprint>is required, taken fromlattris get memory, and the CLI refuses locally before reading stdin when it is missing. A note that moved since that read is refused rather than overwritten: the CLI prints the current words and revision, keeps your draft under~/lattris-machines/<account>/<env>/drafts/and prints the path. When your draft already equals the current words it printsalready savedand exits clean.--overwrite --reason "..."writes over the current words on purpose, and the reason is recorded in the note's history. For any other kind of block the CLI reads first and sends theupdatedAtit read, and--overwriteskips that check.lattris delete memory <id>moves a note to your deleted list. The CLI reads the note first, shows its title, and sends the fingerprint it read, with no prompt. Its history stays, andlattris restore memoryreturns it private. Any other kind of block is removed for good here.lattris get history <id>lists what happened to a note, newest first, one line per operation: created, edited, overwritten, archived, restored, span-deleted, baseline or no-op, with the revision it wrote where it wrote one and the head fingerprint.lattris get deletedlists your deleted notes with their fingerprints.lattris restore memory <id> --revision <n> --base-fingerprint <head>makes revision n the current words as a new revision. Both flags are required, taken fromget history.lattris erase memory <id> --base-fingerprint <fp>removes a note from the deleted list for good, words and history both. It asks you to confirm at a terminal and takes--yeselsewhere. This is the one command with no undo.Every command that changes a note carries a session id.
LATTRIS_SESSION_IDsets it, a terminal gets one of its own, and a script without one is refused before the network.LATTRIS_AGENTnames the role, andwhoamiprints both.lattris share memory <id> --with publicgrants.lattris unsharewithdraws.lattris setupmoves an AI workspace onto Lattris as its memory. In a terminal it also offers to allow lattris commands in Claude Code, one entry in the repo's.claude/settings.json, and only a person says yes; off a terminal it adds nothing and says so.Your own settings live on your person record.
GET /person/mereads them back with your preferences;PATCH /person/mewrites them, replacing a preference whole per key. Two keys exist:banned_words, the list the write doors check, andask_requires_role_lines, true or false, which makes your questions door refuse an ask that lacks a For line or a Filed by line.A two-word verb handed to the CLI as one word is refused with words that name the right form.
--as <email>acts as another local actor for one command.--env prodtargets production instead of local. Sessions are stored per actor, per environment.
Reading
GET /memories/{id} returns a block. Add ?links=1 for its edges and their reasons, and &content=0 when you only need the shape. Every link's node and the anchor's own node carry href, the absolute read address of that block with links=1, in the public form on the public door and the authenticated form on this one, so a reader follows a link by opening its href and never builds an address. The public read door answers the block as a page when the caller's Accept prefers text/html or format=html rides on the address: every link is a real anchor carrying its verb and reason, and a footer anchor points at the JSON form. format=json forces JSON whatever the Accept says, and a JSON caller sees no change. The door speaks both. The website's public pages, the homepage and any public block address, carry the block's title, summary, words and one anchor per link in their HTML before any script runs. They are filled from the anonymous read, so a private block reads not-found for everyone, with a short shared cache keyed on the block's updatedAt, and the plain shell when the fill cannot happen. On the site, a public read shows the block with the graph's controls for anyone, the wall or graph switch, the layout, the depth and the filters, and only the people rail is the owner's. On the graph, titles show for the selected block, its neighbours and what is near or under the pointer, with a few landmarks when nothing is selected. On a phone, closing a block's card leaves the graph with nothing selected, and the wall is never shown.
GET /memories lists newest first and takes filters: q= searches titles and content, topic= narrows to one topic's members. The list door and lattris get memories take content=0 like the single read: the list then answers each block's shape, its fingerprint, revisionNo and structureHash among the fields, and no excerpt, for a reader who lists before it reads.
A public tree is read at /by/{handle}/memories without signing in, and a block can be addressed by its full id, its short handle, or its slug. An id obtained outside the tree can return 404, and a 404 deliberately does not tell you whether the thing does not exist or is simply not yours to see.
A note keeps its history, for its owner. Every edit of a note keeps a complete copy of the title, words and summary before it. Deleting and restoring are recorded but write no copy, since they change no words. Restore makes a past copy the current one as a new revision, so history is never rewritten. Links, topics and grants keep no trail. Public readers see only the current words, never the trail. History begins at the first revision kept, and a note has no revisions from before that. Sessions, recordings, photos and videos have one version, the current one. A captured session's transcript may describe what a block used to say. That is a description of the edit, not the old text.
Writing
POST /memories/notescreates a note, and takesaiSummaryin the same call. The create door refuses unknown fields rather than ignoring them. It requires anIdempotency-Keyheader, a UUID minted for that attempt and resent verbatim if the answer is lost, and refuses a create without one with400andmemory:idempotency-key-required.PATCH /memories/{id}corrects a block, and re-declares intent: omit the field and it stays as it was, send null and it clears. For a note it takesbaseFingerprint, thefingerprinta content read returned, and the headerX-Lattris-Session-Id. Missing fingerprint:428andmemory:precondition-required, nothing written. Fingerprint no longer current:409andmemory:stale-write, nothing written, withdetail.currentholding the note as it stands and the message "This block changed after your read. Nothing was written. Your draft is unchanged; compare it with detail.current before saving again." A note on the deleted list:410andmemory:deleted. To write over the current words on purpose sendoverwrite: { reason }instead of a fingerprint, with anIdempotency-Keyheader. For every other kind of block the door takesexpectedUpdatedAt, theupdatedAta content read returned.DELETE /memories/{id}on a note moves it to the deleted list. It carries the headerX-Lattris-Base-Fingerprint. Archiving a public note unpublishes it. On any other kind of block it removes the block for good.GET /memories/{id}/revisionslists what happened to a note, one item per operation, newest first, withrevisionNonull where no copy was written.GET /memories/{id}/revisions/{n}returns one copy in full, andGET /memories?state=deletedlists the deleted notes. Owner only: anyone else gets the same 404 as for a block that does not exist.POST /memories/{id}/restoretakesrevisionNoandbaseFingerprint. An active note keeps its grants, a deleted one comes back private.POST /memories/{id}/erasetakesbaseFingerprintand works only on a note in the deleted list. It leaves a marker with the id and handle and nothing else. Every edge that pointed at it keeps its reason, and any read that carries edges returns the erased end as a stub with id, handle and type only.- Both of those doors check the title and the content against a list of banned words. The list is yours to set, and a preference you never set is not the same as one you set to nothing: absent means the door was never asked to check, empty means you asked it to check against nothing. A refusal names every word it caught and the field that overrides it,
allowBannedWords: true. The house voice says which words and why. POST /memories/{id}/edgeslinks two blocks you own. The body takesverb,toBlockIdandreason. All three are required; omit one and the call returns400.PATCH /memories/{id}/edgeschanges an existing edge's reason. It takesverb,toBlockIdand the newreason. The verb and the two ends are the edge's address, not fields you can edit — send a different verb and you get a404, because you have named an edge that does not exist.DELETE /memories/{id}/edgestakesverbandtoBlockId, noreason.PUT /memories/{id}/topics/{topicId}adds a membership. Sending it twice does nothing the second time.POST /memories/recordingskeeps a recording. It stands on any one of three things: held audio, a source it lives at, or a transcript. The transcript arrives whole, speakers and turns together.PUT /memories/{id}/speakerscorrects who a voice belongs to, keyed by the speaker label the transcript already carries.POST /spanscreates a span, and a block joins one when it is created.
Storage
Uploads go to an R2 bucket, and the API signs each upload address itself. An upload claim that fails after its reservation, at the hub, the asset or the signing, puts the bytes back on the storage meter, so a failed claim never costs an owner storage.
Sharing
Everything is private until granted. POST /memories/{id}/grants makes a block public; DELETE /memories/{id}/grants/public withdraws it.
What you grant is the block, not the account, so a tree can be half published. Absence of a grant is the privacy state, and the server enforces it: a stranger reading a private tree gets the same answer as a stranger reading a tree that does not exist.
The door for questions
When a published tree cannot answer something, POST /by/{handle}/questions leaves the question with its owner. Signing in is required — a question carries its asker. There is no live reply, by design. The answer is the tree changing before the asker comes back.
Who is reading
Three kinds of caller reach this surface:
- The tree's owner — sees everything, private included.
- A signed-in person — sees what was granted to them, and writes only to their own account.
- Anybody without a credential — sees only public blocks, at
/by/{handle}alone.
The server decides which you are from the credential. Nothing asks you to declare it.
Probe. Asked to show what a note said before its last edit, which door does a session that has read only this block use, who may open it, and what does it answer for a session or a recording?