Using it — the doors, the CLI and the API

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.

Getting started

Install the CLI: npm install -g lattris (Node 22 or newer). Sign in: lattris login. Read a public block without signing in: lattris get memory <url>, or open any block's web address with ?format=json for the data.

The CLI

  • lattris login signs in through the browser. Lattris is invite-only at this stage, so sign-in works only once somebody who holds an invitation has sent you one. --device signs in a machine with no browser at all: it prints an address and a code, and you enter the code on any other device you have. Minting an invitation is closed unless your own record carries can_invite; whoami reports it, so you can tell before trying, and the door answers 403 with invite:not-permitted to everyone else. No route turns it on — it is granted by hand for now, and listing or revoking invitations you already hold stays open regardless.

  • lattris whoami prints who the session acts as, and quietly refreshes an expired token. lattris logout drops 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 memory saves a note from stdin. --ai-summary gives 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 session saves a whole conversation from its transcript, a main session's or a subagent's own, and says which shape it read. lattris capture recording keeps 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 --reason writes an edge, lattris unlink <from> <to> --verb removes it, and lattris update edge <from> --verb --to --reason rewrites the reason. --reason is 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>|clear names who spoke.

  • lattris contact create "<name>" [--ai] records a person, or an AI role with --ai. lattris contact list lists yours, lattris contact get <id> reads one by its full id or a unique prefix, and lattris 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-words shows 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, and whoami shows it.

  • lattris close question <id> --close-as updated|declined|already-answered closes an ask. --block is required for updated and already-answered, and --note for declined.

  • lattris get memories lists. 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 carries fingerprint, revisionNo and structureHash, a hash over its current links and grants, and every edge carries a hash. 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-words as the override. Three flags ride with it:

    • --title retitles
    • --intent <name> re-declares what it is
    • --clear-intent takes 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 from lattris 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 prints already saved and exits clean. --overwrite --reason "..." writes over the current words on purpose, and the reason is recorded in the note's history. For a photo, video, session or recording, --expected-updated-at <time> is required instead, the updatedAt that read returned, and --overwrite skips that check. A session takes no new words at all: its turns are only ever added to, never rewritten, so the command touches only its title, summary, intent or contributor, and new turns arrive through lattris capture session.

  • 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, and lattris restore memory returns 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 deleted lists 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 from get 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 --yes elsewhere. This is the one command with no undo.

  • Every command that changes a note carries a session id. LATTRIS_SESSION_ID sets it, a terminal gets one of its own, and a script without one is refused before the network. LATTRIS_AGENT names the role, and whoami prints both.

  • lattris share memory <id> --with public grants. lattris unshare withdraws.

  • Sharing commands: share, unshare, deny, undeny, get access and get memories --for. Run any of them bare, or with --help, for its exact shape.

  • lattris setup moves 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/me reads them back with your preferences; PATCH /person/me writes them, replacing a preference whole per key. Two keys exist: banned_words, the list the write doors check, and ask_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 prod targets production instead of local. Sessions are stored per actor, per environment, under ~/lattris-machines/<account>/<env>/session.json, which only you can read; lattris logout removes it.

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.

A public read carries two things after the links that an owner's read of the same block does not, dictionary and instructions_to_ai. Both are for an AI that arrives knowing nothing about Lattris. The dictionary holds two entries, what Lattris is and what the intent scale means. instructions_to_ai is one paragraph telling that AI what kind of thing it is holding and to open by saying what the block is about, worded one way for a recipe and another for every other intent. The platform writes both, so the words are in the code rather than here, and they sit last so they are the freshest thing the AI reads. The HTML form carries them too: the paragraph at the end of the main content, the dictionary as a definition list. Every linked node carries its intent, so a reader can tell a recipe to follow from a truth to know without opening it.

On a public block page, Read this with your AI offers three copies, and the person picks by what their AI can do: the address alone, for one that fetches; the block as text, with the instructions, the words, the linked blocks and the dictionary all in the paste, for one whose fetcher cannot reach a new domain; or both together.

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/notes creates a note, and takes aiSummary in the same call. The create door refuses unknown fields rather than ignoring them. It requires an Idempotency-Key header, a UUID minted for that attempt and resent verbatim if the answer is lost, and refuses a create without one with 400 and memory: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 takes baseFingerprint, the fingerprint a content read returned, and the header X-Lattris-Session-Id. Missing fingerprint: 428 and memory:precondition-required, nothing written. Fingerprint no longer current: 409 and memory:stale-write, nothing written, with detail.current holding 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: 410 and memory:deleted. To write over the current words on purpose send overwrite: { reason } instead of a fingerprint, with an Idempotency-Key header. For every other kind of block the door takes expectedUpdatedAt, the updatedAt a content read returned.
  • DELETE /memories/{id} on a note moves it to the deleted list. It carries the header X-Lattris-Base-Fingerprint. Archiving a public note unpublishes it. On any other kind of block it removes the block for good.
  • GET /memories/{id}/revisions lists what happened to a note, one item per operation, newest first, with revisionNo null where no copy was written. GET /memories/{id}/revisions/{n} returns one copy in full, and GET /memories?state=deleted lists the deleted notes. Owner only: anyone else gets the same 404 as for a block that does not exist.
  • POST /memories/{id}/restore takes revisionNo and baseFingerprint. An active note keeps its grants, a deleted one comes back private.
  • POST /memories/{id}/erase takes baseFingerprint and 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}/edges links two blocks you own. The body takes verb, toBlockId and reason. All three are required; omit one and the call returns 400.
  • PATCH /memories/{id}/edges changes an existing edge's reason. It takes verb, toBlockId and the new reason. The verb and the two ends are the edge's address, not fields you can edit — send a different verb and you get a 404, because you have named an edge that does not exist.
  • DELETE /memories/{id}/edges takes verb and toBlockId, no reason.
  • PUT /memories/{id}/topics/{topicId} adds a membership. Sending it twice does nothing the second time.
  • POST /memories/recordings keeps 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}/speakers corrects who a voice belongs to, keyed by the speaker label the transcript already carries.
  • POST /spans creates 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. On lattris.com uploads are not open for now: every door that signs an upload answers 503 with storage:endpoint-not-configured. Photos, videos and a recording's own audio cannot be added there. A recording can still be kept from its source or its transcript. 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.

Datasets

Rows written by scripts, kept beside your notes and read back by your AI instead of fetched again: market prices, and for a card collector the cards, what each sold for, what they hold, what it is worth, what they watch for, and the listings open now.

  • lattris get datasets lists every dataset with the span of rows you hold in it. lattris get dataset <name> describes one: its fields, what identifies a row, how it pages and what it filters by. That describe door is the one home of a dataset's shape, so read it rather than this page.
  • lattris capture rows <name> takes rows on stdin, one JSON object per line. A row with the same identity as one you already hold replaces it.
  • lattris get rows <name> [field=value …] pages newest first; --from and --to take a moment or a bare day. lattris delete row <name> <id> removes one.
  • Every row is yours alone. The same identity on two accounts is two rows, and nobody else's read finds yours.

A dataset has one of three shapes. An item is what is true of a thing now. A reading is one observation at a moment. An event is one thing that happened, identified by one or more of its fields, so a listing seen twice is two rows and a valuation revised an hour later stands beside the first.

Not built: an account defining its own dataset, and sharing rows with a contact the way a note is shared.

Sharing

Who can see a block

  • You see every block of your own, except a note you have moved to the deleted list.
  • Nobody else sees a block until you share it. A block with no allow on it is yours alone.
  • You share one block at a time, never your whole account.

Two kinds of sharing

  • On the website: public (anyone, no account) or a person's account, named through a contact linked to it. These can only be allowed. A contact becomes linked when that person accepts your invitation, or your link request: lattris contact link <contact> --handle <h> asks the account at that public address, and they answer from their inbox with lattris accept contact <id> or lattris decline contact <id>. Only your name and handle reach them.
  • Contact access: one contact, or all your contacts. Each can be allowed or denied. A contact needs no Lattris account. Nothing reaches their side; it is your own record of who may see what.

Allow and deny

  • An allow opens a block to an audience. A deny shuts it to them.
  • You can deny a contact who has no allow, as a standing "never".
  • One setting per audience on a block. Denying someone you had allowed replaces the allow.

In this order

  1. A deny that reaches the contact shuts the block, whatever allow also reaches them.
  2. Otherwise, any allow that reaches them opens it: public, all contacts, that contact by name, or their linked account.
  3. Nothing reaching them means shut.

What "reaches" means

  • A share to all contacts reaches every contact you have, AI contacts included, and contacts you add later.
  • A share to one contact reaches that contact only. Denying Sam does not shut it to Alex.
  • A deny to all contacts shuts it even to someone allowed by name.
  • A phone number none of your contacts holds is a stranger. A stranger sees your public blocks only.
  • An archived or erased block is shut to every contact.
You set Alex Sam Stranger
nothing shut shut shut
allow Alex open shut shut
allow all contacts open open shut
allow all contacts, deny Sam open shut shut
deny all contacts, allow Alex shut shut shut
public open open open

A block can show up in two places:

  • on the website (public, or shared with someone's account)
  • in what a contact may see (an allow or a deny)

The website does not understand denies. So Lattris stops you creating a contradiction.

Example: you have denied Sam a note.

  • You try to make that note public. Refused: Sam could just read it on the web.
  • Lift the deny first, then it works.

The other way round: a note is public.

  • You try to deny Sam. Refused. Take it off public first.

It only blocks real contradictions. Denying Sam does not stop you sharing with Alex's account. The refusal always tells you what is in the way.

If Alex has a Lattris account (his contact is linked)

  • share --with Alex does two things: Alex's contact may see it, and it is shared with his account on the website.
  • The website half needs your display name set, because he sees who shared it. No name: the first half still happens, and the answer tells you the website half was skipped.
  • If Alex is already denied, say by "deny all contacts", the allow is recorded, but he still cannot see it and no website share is made. The answer tells you that too.
  • unshare --with Alex removes both halves.

Taking things back. Three commands:

  • unshare: "Alex can no longer see this." Takes away an allow.
  • deny: "Alex must never see this."
  • undeny: "Forget that never." Takes away a deny. (Becomes remove deny when the rename ships.)

undeny asks "are you sure?" first, because it is the only one that opens something up. The other two do not ask.

You cannot mix them up. unshare will not remove a deny, and undeny will not remove an allow. Lattris says no and tells you which one to use.

Checking what someone can see. Two questions you can ask:

  1. "What can Alex see?" lattris get memories --for Alex, or by number with --for-phone +61…. You get the list of your notes Alex can see.
  2. "Can Alex see this note, and why?" lattris get access <note> --for Alex. It answers open or shut, and what decided it. For example: "shut, because: deny all contacts".

The list shows only what Alex would see. Your private details are left out: who else it is shared with, your topics, who contributed. If a note links to another note Alex cannot see, that link is left out too.

Old versions

  • When you edit a note, Lattris keeps the old versions. Only you can see them.
  • Anyone you share with sees the note as it is right now.

A deny does not disappear by accident

  • Deleting a note and bringing it back: the allows are gone, the denies are still there.
  • Deleting a contact who has a deny: Lattris says no, and shows you which notes. Remove those denies first. Otherwise, adding the same number back later would get past the deny.
  • Deleting a contact who only has allows: fine. The allows go with them.

Phone numbers

  • Written in full: + then 7 to 15 digits, for example +61412345678.
  • Two of your contacts cannot share a number.

Setting it when you create something

  • You can set allows and denies at the moment you save a note, a session or a recording. So it is never visible, even for a second, to someone it is denied to.
  • If a save is sent twice, the second one gets the first one's result. Nothing is doubled.

Not built yet

  • Groups of contacts, like "family".
  • The website showing a contact anything from contact access.

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? Asked whether a contact with no account can see a block allowed to all contacts and denied to that contact, what does it answer, and what does the website show that contact?

Leads here