Skip to main content

25  Domains

This chapter is the lookup for everything you can set on a domain and every rule you run into while doing it: reach for it when a prompt to the agent isn’t enough (a script, a container or the question of why a command refused). The prose chapters keep the why (domains in Section 4.1, private domains in Chapter 16, team domains and review mode in Chapter 17, membership in Chapter 19, provisioning in Chapter 21). The MANIFEST’s own keys are in Chapter 24 and every other setting is in Chapter 23.

25.1 Kinds of domain

Every domain is one of three kinds, and the same tools read and write all of them. Whether a domain lives in files or in the database is fixed when you register it, and a file domain turns into a team domain once you connect an origin to it.

file domain
A folder of markdown files with a MANIFEST.md at its root, the default kind. The files are the source of truth and the index is derived from them, so a rebuild of the index loses nothing.
virtual domain
A domain that lives in the database with no folder on disk, for a container without a writable volume or a Postgres backend shared by several machines. Its engrams live only in the index, so removing it deletes them and crystalline reindex --wipe refuses while one is registered.
team domain
A file domain with an origin: a GitHub repository, an optional subfolder in it and a branch. It needs github.enabled and a GitHub connection, and it can’t be virtual, because a proposal needs files to carry.

25.2 The registration block

Each registered domain is one entry under domains: in the global config.yaml, keyed by its name, and the commands in this chapter write these entries for you. crystalline config set doesn’t reach them, so a key without a command is a hand edit.

domains:
  books:
    path: ~/Documents/Crystalline/books
    provision: true
  club:
    path: ~/Documents/Crystalline/club
    origin:
      repo: dallas/book-club
      path: knowledge
      branch: main
      poll_secs: 600
    review: overlay
  reading:
    kind: virtual
kind
Either file or virtual, and it only appears on a virtual domain, since a file domain leaves the key out.
path
The domain’s root folder, with a leading ~ expanded; a file domain registered without one lands at <domains_root>/<name>, and a virtual domain has none.
origin.repo
The GitHub repository of a team domain, as owner/name; its presence is what makes a file domain a team domain.
origin.path
The subfolder of the repository that is the domain root, for a repository that holds more than knowledge (absent means the repository root).
origin.branch
The branch the domain tracks. Adding a team domain without a branch writes the repository’s default branch here, asked from GitHub; an entry without the key, from before that or written by hand, tracks main.
origin.poll_secs
How often the daemon checks this origin for merged work, in seconds. Absent defers to the global github.poll_secs (300 unless set), and any value below 60 is raised to 60. No command sets it, so it’s a hand edit.
provision
This machine’s decision on the artifacts the domain ships: true installs them into every onboarded harness, false keeps them out and removes what was installed. Absent means undecided, and nothing installs.
review
overlay puts the domain in review mode; absent means the domain takes changes directly, which is how every domain starts.

25.3 Names

A domain has a name on this machine and a name it declares. The local name is the key in the config and the name in Fluid’s addresses, and a new domain’s folder under the domains root takes it. The declared name is the MANIFEST’s domain_name (Chapter 24), and it’s the one links, crystalline:// addresses and the agent’s routing carry. Usually the two are the same. Where they differ, both reach the domain and a link written here is stored with the declared name, so it works on every machine. Choose the declared name once: a rename is there to repair a name that turned out wrong (Chapter 18).

What a name may be
Letters and digits in any script, plus -, _ and ., up to 64 characters, with no dot at the start or the end. The Windows device names (CON, PRN, AUX, NUL, COM1 to COM9, LPT1 to LPT9) are refused on every platform. The command line, add_domain, Fluid and the API all run this check, and so do domain init --name and a rename, with the same message everywhere. A name registered before the check existed keeps working.
Names taken already
Adding a name that is already registered at the same folder, or as the same virtual domain, changes nothing and indexes it again, so every way of adding a domain is safe to repeat. The same name at another folder or as the other kind of domain is refused, and the message names what holds it. A name that differs from another domain’s here only in letter case (eng and Eng) is refused too, whether you add, rename or connect, since a disk that ignores case would give both one state folder; a name taken from a folder or a MANIFEST steps aside instead. Every surface also refuses a name that an environment variable already defines, and the refusal names the variable, because that domain belongs to it. A declared name that another domain here already uses as its local name is taken too: the new domain is registered under it with -2 added and reported as shadowed, and links that use the declared name keep reaching the domain that holds it.
Names given for you
A domain added without a name takes the domain_name its MANIFEST declares, then the folder’s or the repository’s name, with a suffix when that one is taken; a name made this way is always turned into a valid one (a folder called CON becomes con-2). add_domain, domain add and Fluid all pick it this way, and when you connect a repository Fluid shows the name it will take in the empty Name field before you create the domain.
Former names
A rename keeps every former name working on this machine as an alias: a link or an address that uses it still reaches the domain, and an old /d/<old>/ address in Fluid sends you to the new one. An alias gives way to any domain whose local or declared name it is.
When the declared name changes
A machine follows a changed domain_name on its next sync when its local name was only ever a default, and keeps the old one as an alias. A local name somebody chose on purpose stays, and the declared name reaches the domain there as well. A shadowed domain takes its declared name on the first sync after the domain that held it gives it up, unless its local name was chosen on purpose.

25.4 Registering a domain

There are five ways in: the agent’s tool, the command line, Fluid, the JSON API behind it and environment variables. Each registers the domain, and all but --no-sync index it at once.

add_domain
The MCP tool, with domain, folder, virtual, repo, path, branch and readers. The arguments pick the kind: repo makes a team domain, virtual: true a virtual one (it needs domain), anything else a file domain, and repo and virtual can’t be combined. On a server with a GitHub App, repo needs readers as well, shared or private.
add_domain on a folder
A folder without a MANIFEST.md gets a starter one, and the files already there are kept and adopted in place. Without folder the domain lands at <domains_root>/<domain>.
Who may call add_domain
Any local session may. Over HTTP, on an instance where agents sign in, it takes the instance admin role, and a read-only instance refuses it.
crystalline domain add [name] [path]
Registers a domain and indexes it at once, in <domains_root>/<name> when no path is given. A lone argument is always the name; to let the folder name the domain, point at it with --path or as the second argument. A file domain needs a MANIFEST.md in its folder already and refuses with the domain init line otherwise.
--virtual
Registers a virtual domain and scaffolds its MANIFEST into the index; it takes no path and can’t be combined with --origin.
--origin owner/repo[/subfolder]
Connects a team domain and downloads the repository, or the subfolder given after its name, into the domain’s folder; it needs github.enabled and a GitHub connection, a prior crystalline connect github or a GitHub App on the server. A folder that already holds knowledge, or a registered domain with no origin yet, is connected in place: nothing local is overwritten, and files that differ from the repository become local changes to share.
--branch
The branch to track, and it only works with --origin. Left out, Crystalline asks GitHub for the repository’s default branch and records it; when GitHub can’t answer, the add refuses and asks for --branch.
--readers shared|private
Who may read a team domain on a server with a GitHub App: shared is everyone signed in, private only the people with access to the repository on GitHub (Chapter 19). On a terminal the command asks when the flag is missing, and where nobody can be asked it refuses, so nothing is chosen for you. A shared connect never opens a domain that is already private. It only works with --origin, and a server without an App ignores it with a note.
--no-sync
Registers a file domain without indexing it, for a script that runs crystalline sync later; a team domain is always indexed as it connects, so the flag refuses with --origin.
--private and --owner <account>
Registers the domain private from its first minute, owned by the named account. --private needs --owner, and before anything is registered the account has to exist and be enabled. A domain that is already registered is refused here; crystalline domain visibility makes that one private.
crystalline domain init <path>
Creates the folder if needed and writes a starter MANIFEST.md with the Scope, When to Use and Notes for Agents sections, then prints the domain add line to run, or says a MANIFEST is already there and leaves it alone. --name sets the name in the manifest, the folder’s name otherwise. It never touches the config.
crystalline domain list
Lists the registered domains, with engram counts when the index is there. A domain whose declared name differs from its local name shows both, as local (declared), with its former names beside it and a mark when another domain holds its declared name. list_domains and the JSON API’s domain list report the same three things.
Fluid’s New domain dialog
Only admins get the dialog. Its Kind is Local folder, Virtual or GitHub team, and the fields follow it: a Name for the first two; a Repository, Branch, Folder in the repository and an optional Name for a team domain. A local domain always lands under the server’s domains root, since a browser can’t name a folder on the server, and the Private box makes the domain private with you as its owner. On a server with a GitHub App a team domain also asks “Who may read this domain?”, with no answer chosen in advance.
POST /api/v1/domains
The request behind the dialog, for a script, with mode (local, virtual or github), name, repo, branch, path, private and readers as its fields. A field that doesn’t belong to the mode is refused rather than ignored, and a team domain on an instance with no GitHub connection is refused with where to make one.
CRYSTALLINE_DOMAIN_<NAME>=<path>
Registers a file domain for as long as the variable is set, for a container that shouldn’t carry a config file. The domain’s name is <NAME> in lower case with _ turned into -, and the variable replaces a config.yaml entry of that name, with a warning at startup, without ever being written into the file.
CRYSTALLINE_DOMAIN_<NAME>_ORIGIN
Attaches an origin to that domain, as owner/repo[/subfolder][@branch], so a new node downloads the team domain on its first start; with CRYSTALLINE_GITHUB_ENABLED off the domain waits and says so, and without the matching CRYSTALLINE_DOMAIN_<NAME> the daemon refuses to start. Because the suffix is read first, no variable can define a domain whose name ends in -origin, -review or -readers, so register such a domain in the file, and look in Chapter 23 for the GitHub token variable that goes with the origin.
CRYSTALLINE_DOMAIN_<NAME>_READERS
shared or private, who may read that team domain on a server with a GitHub App. Without the variable the domain is private, with one warning at startup. It’s applied on every start and only ever closes: shared never opens a domain that is already private. It needs the _ORIGIN variable beside it, and a server without an App ignores it. A domain whose readers come from here changes them only here.

25.5 Renaming a domain

A rename changes the domain’s local name and, unless you keep it to this machine, its declared name too: it writes domain_name into the MANIFEST and rewrites every link and crystalline:// address that uses an old name, in every domain you can write. A MANIFEST whose frontmatter is in flow style ({title: ...}) is left as it is, and the rename’s report and, in Fluid, the domain page say to set domain_name by hand. A domain you can only read keeps its links as they are, and the rename lists it. In a team domain the rewrites become local changes to share, and in review mode they land in your draft. No MCP tool renames a domain, so an agent asked for one points you to the entries below.

crystalline domain rename <domain> <new>
Renames the domain, named by its local name, its declared name or any alias. It works on this machine’s own index and configuration only, and it refuses before anything moves when --db or --config points elsewhere.
--local
Renames only this machine’s records: the index, the state folders, the provisioning receipt and the config entry move, and the MANIFEST and every link stay as they are. It’s the way out when the MANIFEST can’t be written here, which the refusal names.
POST /api/v1/domains/{domain}/rename
The same for a script, with name and local_only as its fields. The answer says what moved, which engrams were rewritten, which domains were left behind and which aliases the domain answers to now.
Rename domain
An item in the More actions menu on the domain’s page in Fluid, drawn for admins and for a private domain’s owner, and the Change name button in Domain policies, in the domain’s settings, opens the same dialog. Its “This machine only” box is --local. While a rename runs the page says Renaming… and holds back every write, and afterwards it says which name the domain was renamed from, along with the domain_name note when the MANIFEST was left as it was. When another domain here holds the declared name, a banner at the top of the page says so, with a Rename button beside it.
Who may rename a domain
An instance admin, a private domain’s owner or the machine operator from the command line.
A rename that stops halfway
A crash or a killed process leaves a journal in the state folder, and the daemon, or the next command, finishes the rename. When it can’t, crystalline doctor shows the rename under its rename: heading with the steps done, the steps left and the command that finishes it. crystalline doctor --discard-rename drops the journal and leaves the steps already run as they are, for you to finish or undo by hand; doctor --fix never drops one.
Names in crystalline doctor
Under its domain names: heading doctor lists every collision: a domain whose declared name another domain here holds, two domains that declare the same name (so links that use it reach neither) and two domains whose names differ only in letter case, with the rename that ends it. It names each team domain without a domain_name and asks you to have its owner add one. It also lists the links in your own domains that use a name only this machine knows, a former name that no longer reaches the domain because another domain answers to it and a domain still waiting to take its declared name, with the reason. doctor --fix rewrites those links the ordinary way, as a change to share in a team domain and a draft in review mode, and clears an index entry an older version left behind when a domain was removed.

25.6 Moving knowledge in and out

These commands copy engrams between a domain and a folder or an archive, and none of them changes the domain’s registration. A single engram moves from one registered domain to another with move_engram and its destination_domain. The links to it are rewritten in the domains you may write, the moved engram’s own links keep finding what they found, and the receipt names every link it couldn’t fix, so somebody who may write there can.

crystalline domain import <path> --domain <name>
Loads engram files into a registered virtual domain. A file that already has every field a reader needs is stored as it is, and one that lacks some is completed the way crystalline import completes it: a missing status becomes stable and a file without tags gets the tag imported. The pictures and documents the notes link to are stored as attachments under assets/, and the report lists every link it couldn’t take. A file domain is refused: for a folder of notes that still needs converting, crystalline import writes into the domain’s folder and crystalline sync indexes it.
--overwrite and --dry-run on domain import
An engram whose path or permalink already exists is skipped unless --overwrite says otherwise, and --dry-run reports what would land and writes nothing.
crystalline domain export <path> --domain <name>
Writes any domain’s engrams out as an ordinary markdown folder, which is how a virtual domain’s content gets checked with crystalline verify or turned back into files. --force writes into a folder that isn’t empty, and --dry-run only reports.
Fluid’s Backup section
The section sits in the domain’s settings, above the Danger zone, and is drawn for admins; the domain page’s More actions menu has Download archive and Import archive too. Download archive saves a zip of the whole domain, MANIFEST and attachments included, and works on a read-only instance too. An archive from a file domain restores into a virtual one and the other way round.
Import archive
Uploads such a zip into the domain, shows a preview of every entry first and imports only after you confirm. Skip existing, the default, leaves a path that’s already there alone, and Overwrite existing replaces it. A MANIFEST in the archive is never imported, a file whose name Windows can’t hold (like CON.md or a name that ends in a dot) is skipped on every system and named in the report, and a broken or oversized archive is refused as a whole.

25.7 Read-only domains

A domain can be frozen with read_only: on in its MANIFEST (Chapter 24), for a domain that is finished and should stay as it is. It’s a switch on one domain, where service.read_only makes a whole instance read-only. It guards what goes through Crystalline against accidents and is no sandbox: an agent with a shell can still edit the MANIFEST on disk.

What it refuses
Every write into the domain through Crystalline, from an agent over MCP, a person in Fluid, the command line or the REST API alike: writing, editing (frontmatter changes and evolve acknowledgements included), moving or splitting an engram into or out of the domain, deleting, attachments, review-mode drafts, co-editing saves, imports, switching review mode, a configure change of its rule overrides or of a policy other than read_only and renaming the domain. A rename for this machine only (--local) still works, because it writes nothing into the domain. Sharing changes is refused too, since nothing local can change, and so is withdrawing a proposal. The refusal names the domain and says that its owner switches read_only off in the domain policies.
What keeps working
Team pulls keep the local copy current, because the team’s repository stays the authority. Discarding local changes and resolving a conflict with the team’s side work too, since both go the way a pull goes, though an agent may not do either to the MANIFEST while the switch is on; a person does that in Fluid or over the REST API. The index, sync and search work as before. On a team domain a switch turned on here stays on this machine, because sharing is refused, so switch it on in the team’s repository when the whole team should follow; when the team switches it off there, the next pull opens the domain here too.
How it shows
list_domains, browse_domain and read_engram answer read_only: true, and the domain’s line in the briefing says “(read-only)”, so an agent doesn’t plan a write there. The same mark appears on a domain you may read but not write, as a viewer or with a write list that leaves it out (Chapter 16), and the refusal says which reason applies. A domain from a connected server carries the server’s mark. Fluid shows a lock on a locked domain and on every engram page in it, and wherever you can’t write it offers no edit, new, move or delete. The agent’s tool list doesn’t change; the write tools refuse when they’re called.
The sweep
Evolve and the write nudges skip a read-only domain, because nobody could act on its findings, and the findings come back once the switch is off.

25.8 Removing a domain

Removing a domain unregisters it and clears its rows from the index. A file or team domain’s files stay on disk and the team’s repository is never touched: the only knowledge a removal can delete is a virtual domain’s, and that takes an explicit confirmation.

crystalline domain remove <name>
Unregisters the domain and frees its name for a rename or a new registration. Registering the same folder again adopts the files as they are, and a team domain is connected again with its repository, which keeps its origin. The visibility and membership records go too, so a later domain under the same name starts out shared.
--purge
Confirms that a virtual domain’s engrams are deleted with it, and nothing brings them back afterwards; a virtual domain that holds any refuses without it, so export first when you want a copy.
--end-drafts <actor>
Names one other person whose private drafts in a review-mode domain end with the removal; repeat it for each. The refusal says who is drafting and how many drafts each holds, and your own drafts need no naming.
remove_domain
The MCP tool, with domain, purge and end_drafts working as the flags do. On a harness that can ask, the first call removes nothing and puts a confirmation question to you naming the domain, its kind, its engram count and the drafts at stake. The question says in one line what happens, and its box starts checked, so Accept removes the domain and Decline keeps everything.
Who may remove a domain
The machine operator, an instance admin or, for a private domain, its owner. An agent on an instance that leaves agent authentication off is refused. A domain defined by an environment variable can’t be removed at all: unset the variable instead.
Unregister domain
The button in the Danger zone of the domain’s settings, drawn for admins and for a private domain’s owner. It asks you to type the domain’s name and says whether the files stay on disk or, for a virtual domain, are deleted.

25.9 Domains from shared servers

Your machine’s Crystalline can connect to a server and offer its domains beside your own, each domain from its one source (Section 16.10). A server’s domains get no entry under domains:; the sources and the local names live in remote/sources.json in the state folder. The sign-in itself is kept in the OS keychain under crystalline-server:<host>, with the path after the host for a server under a path (crystalline-server:example.com/crystalline), or in a private file where no keychain works; it’s never in sources.json.

crystalline connect <url>
Adds a server as a source; the server has to run Crystalline 0.23 or newer. connect keeps the address with its path and follows no redirect, so https://example.com/crystalline connects to a server under that path. An address with a query or a fragment, or with a path that breaks the rules of service.public_url (Chapter 23), is refused. A server under another path of the same host is a second source, with its own sign-in. It checks the server first, refuses plain http:// anywhere but loopback and signs you in through the browser, so the server needs auth.oauth on (it follows auth.mcp).
--token
Takes a personal token instead of the browser sign-in, typed at a prompt or piped in and never on the command line, for a machine without a browser. A server without the sign-in path gets the same, with a message saying so.
--name
The source’s name, which appears only in notes and in local names. Without it the source is named after the most specific word of its host that isn’t a generic one like crystalline, and a second server on the same host gets the same word with -2.
--domains <names>
Takes only these domains of the server, by their names there, separated by commas (--domains alpha,beta). Connecting again with --domains replaces the list, and a connect with neither flag keeps the list as it is. A listed name the server doesn’t offer yet (no rights, or not created) stays on the list and is taken once the server offers it; connect says so. Taking a name off the list frees it, and a local copy it hid comes back.
--all-domains
Takes every domain the server offers again and clears the list. A first connect without --domains takes every domain too.
A server’s domain and its name
A domain from a server keeps its name unless your machine already uses it, and then it gets the local name <domain>-<source>, which crystalline domain rename <name> <new> --local changes. Whoever comes later gets the suffix, so no name changes on its own: a later domain on the server becomes <domain>-<source> and a local domain you add later <domain>-local (then -local-2). When your machine holds its own copy of a team domain the server also serves and you take it from the server, the server wins: your copy is hidden while you’re connected, with its files untouched, and connect warns when it holds changes you haven’t shared yet. A hidden copy can’t be removed or renamed while its server is connected. The same team domain on two servers comes from the one you connected first, unless the other one lists it with --domains: then it moves to that one and keeps its name on your machine. connect says so either way.
What a server’s domain refuses
A move between a server’s domain and another source is refused: read the engram, write it into the destination, then delete it. remove_domain and provision refuse a server’s domain and point at crystalline disconnect, and the collaboration tools of Chapter 17 say to share changes on the server.
crystalline disconnect <name>
Removes a source with its sign-in, its cache and its domains, and revokes the sign-in on the server when it can reach it; the source’s URL works in place of its name. A pasted token is yours, so it’s only forgotten here. A hidden copy of a domain comes back. A sign-in that couldn’t be revoked because the server didn’t answer can be revoked later in the server’s Fluid, under Connected clients on your profile, and it stops working on its own after 30 days unused.
crystalline status and crystalline doctor
List every source: its address and account, how it signed in, whether it answers, which domains it takes (every one it offers, or the list), the domains it brings, the local names and the hidden copies. doctor also says when the sign-in expires, counts a server that doesn’t answer as a problem and notes a listed name the server doesn’t offer.
CRYSTALLINE_REMOTE_URL, CRYSTALLINE_REMOTE_TOKEN and CRYSTALLINE_REMOTE_DOMAINS
Add one source from the environment, for CI and containers. The address keeps its path as it does for connect, and CRYSTALLINE_REMOTE_DOMAINS is the list --domains takes; a value that isn’t a list of domain names leaves the source out rather than taking every domain. crystalline disconnect can’t remove it; unset CRYSTALLINE_REMOTE_URL or CRYSTALLINE_REMOTE_TOKEN instead.

25.10 Visibility and membership

Every domain starts shared, visible to every account on the instance. A private domain is visible only to its owner, the instance’s admins and the accounts invited into it, and to everyone else it answers as if it had never been registered (Chapter 16).

crystalline domain visibility <domain> private --owner <account>
Makes the domain private and hands it to that account, which has to exist and be enabled. Making a domain that is already private private again changes nothing, its owner included. On a domain under a GitHub App --owner is ignored with a note and no account becomes its owner, and a domain whose readers come from CRYSTALLINE_DOMAIN_<NAME>_READERS is refused with the variable’s name.
crystalline domain visibility <domain> default
Shares the domain with every account again and forgets its list of members, so making it private later starts from the owner alone.
Make private and Share with everyone
The same switch in the Danger zone of the domain’s settings in Fluid, confirmed by typing the domain’s name. Making a domain private is for admins, and the admin who does it becomes the owner; sharing it again is for the owner or an admin. On a domain under a GitHub App the switch sits on the Members section instead, for a GitHub admin or maintainer or an instance admin, and no account becomes its owner.
none
The membership level that sees no engram in the domain. A member at none works on the domain’s Reflection alone and needs a Reflection level, or the membership is refused.
viewer
The membership level that reads and searches the domain and nothing more. It’s the level an invitation takes unless you name another.
editor
A viewer who also writes and edits engrams in the domain, as far as the account’s instance role allows.
manager
An editor who also runs the membership: inviting, removing and changing levels up to manager. A manager can’t change visibility, transfer the domain or remove it.
Reflection level
An account carries a Reflection level for the shared domains and a membership carries one for its private domain, beside the level on the engrams, which has none for somebody who sees no engram at all. It’s default, reader or contributor. An admin sets it on the account, and the owner or a manager on the membership. With default, viewer gives reader and none gives nothing. A level that is set only ever raises access (Chapter 12).
reader
The Reflection level that reads the domain’s reflections and comments on them: opening, answering, resolving and reopening threads.
contributor
A reader who also edits reflections, creates, moves and deletes them and adds files to them.
curator
A curator also writes sections from the engrams, settles what people changed, edits the pact and replaces, moves and removes files. Curating is never set on its own: everyone who may edit the engrams curates (editor, manager, the owner, an admin and the machine operator), and Fluid shows them as curators.
crystalline domain members <domain> list
Prints the owner and every member with their level, though a shared domain has no owner and no members, because membership only decides anything while a domain is private. The Reflection collaborators follow, on a shared domain too.
crystalline domain members <domain> add <user>
Invites an account, or moves a member to another level, with --level none, viewer, editor or manager and --reflection for the Reflection level; the owner can’t be added, because the owner already holds every level. On a domain under a GitHub App it refuses, like remove and transfer, with “access to this domain follows GitHub; change it on GitHub”.
crystalline domain members <domain> remove <user>
Ends a membership, though the owner can’t be removed this way; hand the domain on first.
crystalline domain members <domain> reflection add <user> --level reader|contributor
Makes an account a Reflection collaborator of this one domain, shared or private, as long as it’s kept as files. The grant only raises: it never shows an engram the account couldn’t see before, and the write list still caps it. It also works for an account with the instance role none. The owner, a manager, an instance admin and, under a GitHub App, a GitHub admin or maintainer give it, and it’s the one grant a domain under a GitHub App still takes.
crystalline domain members <domain> reflection remove <user>
Takes the grant away again.
The Members section
The same list in Fluid, in the domain’s settings: invite by username, level and Reflection level, change a level, remove, and a Leave button for a member’s own membership, which needs nobody’s permission. Reflection collaborators have a list of their own there. On a domain under a GitHub App the section says that access follows GitHub and refuses a change of membership: people with write access see who has access on GitHub, and a GitHub admin or maintainer or an instance admin switches the domain between shared and private.
crystalline domain transfer <domain> <new_owner>
Hands a private domain to another existing, enabled account, which becomes the owner at once. The old owner keeps nothing and is a stranger to the domain unless invited back. It’s for the owner or an admin, and Fluid offers it in Members.
A deleted owner
Deleting the owner’s account leaves the domain with no owner and only an admin can administer it after that, so transfer it before the account goes.
Instance role and membership level
On a shared domain an account does what its instance role allows: a viewer reads, an editor writes, an admin does everything. On a private domain the membership decides what the account may reach, and a write takes the lower of level and role. So an instance viewer invited as an editor writes nothing, and neither does an owner whose instance role is viewer. An account’s write list (Chapter 16) narrows that further: a domain it leaves out is read-only for that account. All of this holds for domains without a GitHub App behind them; for a domain under a server’s GitHub App the second table below replaces the role and the membership.
Action on a domain Who may
Read and search viewer or better
Write editor or better, capped by role and by a write list
Invite, re-level, remove manager, owner, admin
Leave the member
Make private admin
Share again, transfer owner, admin
Review mode on or off owner, admin
Change policies and rule overrides owner, admin
Unregister owner, admin
Register, provision admin
Backup and archive import admin

On a server with its own GitHub App, a domain connected to a repository follows that repository (Chapter 19). The account’s GitHub link and its role on the repository decide, and members, invitations and the owner set in Crystalline don’t count:

Who Engrams, shared domain Engrams, private domain Reflection
Machine owner, instance admin everything everything curate
GitHub admin or maintain write, switch shared or private the same curate, manage the Reflection collaborators
GitHub write write write curate
GitHub triage or read read read read and comment
Instance viewer or above, without access on GitHub or without a link read nothing shared domain: read and comment; private domain: nothing
Instance role none nothing, whatever GitHub says nothing, whatever GitHub says only through a Reflection collaborator grant
Anonymous visitor, where the instance lets one in read, only when the repository is public nothing read, never comment, only when the repository is public

The instance role doesn’t cap a GitHub role, so an instance viewer with write access on GitHub writes, while the write list still applies. On a shared domain an account’s own Reflection level still raises what the row gives, so an instance contributor edits the Reflection there without access on GitHub; on a private domain it doesn’t count. A private domain counts only the repository’s listed collaborators, never the members of an Enterprise who read an internal repository without being listed. A closed domain, whose repository lost the App, was deleted or moved to another owner, opens to the instance admins and the machine owner alone, and no grant opens it. The open tier acts as the account github.agent_identity names, never above editor; without that setting it’s an anonymous visitor.

The command line and a local stdio session are the machine operator and pass every gate in this table. An agent over HTTP on an instance where agents sign in is its user and gets exactly that user’s row, with no tool for membership at all. Where agent authentication is off, an agent is nobody in particular: it reads and writes the shared domains, registers and provisions without a role, changes a domain’s policies other than read_only wherever it may write and is refused when it unregisters one. On a domain under a GitHub App it’s the account github.agent_identity names instead, private domains included, so turn agent authentication on there.

25.11 Review mode

Review mode gives every writer a private overlay of drafts over a team domain’s folder, and the folder changes only with what lands on the team’s branch (Chapter 17). It lives in the registration as review: overlay, never in the MANIFEST, and no MCP tool turns it on.

crystalline domain review <domain> overlay
Turns review mode on. It needs a GitHub origin and a folder, so a virtual domain is refused, and it refuses while the folder holds unshared changes, listing their paths to share or revert first.
crystalline domain review <domain> direct
Turns review mode off, which ends every private draft in the domain. Run without answers it prints the plan (who holds drafts, how many and where two people drafted the same path), and then it needs one --fold or --discard per person the plan names.
--fold <actor>
Writes that person’s drafts into the domain’s folder as they are (your own drafts from the command line or a local session belong to the actor owner).
--discard <actor>
Ends that person’s drafts without touching the folder. Both flags repeat, one per person, and both refuse with overlay.
Review mode in Fluid
The same switch in the domain’s settings in Fluid. Turning it off shows the plan with a fold or discard choice per person, set to fold until you change it.
Who may switch review mode
The machine operator, an instance admin or a private domain’s owner; a domain an environment variable defines is refused, because its variable holds the setting.
CRYSTALLINE_DOMAIN_<NAME>_REVIEW=overlay
Puts a domain defined by CRYSTALLINE_DOMAIN_<NAME> in review mode, and any other value stops the daemon at startup, while an empty one counts as unset. The variable and a hand-edited review: key are taken as written, without the origin check the command makes, so pair them with an origin.
One proposal at a time
A domain in review mode keeps one open proposal, even with github.stacks on. A second share while one is open is refused: somebody else’s proposal is named with the choice to wait for the merge or ask for a withdrawal, and your own is withdrawn and shared again.

25.12 Sharing and sync

Whether a team domain shares through proposals or straight onto the branch is the MANIFEST’s sharing switch. That key and generated_indexes, which decides whether the folder listings travel with a share, are in Chapter 24. The per-domain part of sync is the origin block above.

github.enabled
The switch every team domain needs before it can be added or kept current, and Chapter 23 has it with the other github.* settings.
The background poll
The daemon checks each team domain’s origin on the interval above and brings merged work into the folder, and a read-only instance keeps polling, so it follows the team’s repository on its own.

25.13 Running the loops

A domain’s reflect loop and evolve loop run without a session through a runner (Chapter 14). <loop> below is reflect or evolve. status and watch take --domain once per domain, run takes one and prompt needs one; without it, status covers every domain the caller may run the loop on.

crystalline <loop> status
What’s due per domain, as counts, with a run that’s going, the last run and the webhook’s state; --json prints the answer as it came. It exits 0 when something is due, 1 when nothing is and 2 on an error. An item every run so far left for a person counts as “left for a person” instead of due, until it changes.
crystalline <loop> run
One beat: it takes the lease, starts Claude Code headless with the shipped prompt and only the Crystalline MCP server, shares the run’s changes, records the run and releases the lease. Without --domain it works every due domain in turn. It exits 0 when the run is done or stopped at its cap, 1 when nothing was due or another run holds the lease and 2 when the run failed. claude has to be on the PATH.
crystalline <loop> watch
Checks the status every --interval seconds (60 by default) and runs when something is due, until it’s stopped. It takes the flags of run.
crystalline <loop> prompt
Prints the shipped prompt for one domain, for a runner that starts its own agent, n8n for example; --max-items sets the cap the prompt names. Paste it again after an upgrade, since the prompt can change.
Flags of run and watch
--max-items (20) is the most items one run works, --max-turns (60) the most agent turns, --model the agent’s model and --runner the label the lease, the run record and Fluid show (cli by default). --log-dir moves the run logs from runs/ in the state folder; the newest 50 per loop and domain are kept, readable by you only. --no-share keeps the changes local. run alone takes --dry-run, which shows the command it would start and starts nothing, and --force, which runs while the loop backs off after failed runs.
Local or remote
Without anything set, the commands talk to the daemon on this machine and a run acts as the machine owner. With CRYSTALLINE_REMOTE_URL and CRYSTALLINE_REMOTE_TOKEN (or CRYSTALLINE_REMOTE_TOKEN_FILE) they talk to that server as the token’s account, and the agent reaches the server’s MCP endpoint with the same token.
Who may run a loop
The reflect loop needs the right to curate the domain’s Reflection, the evolve loop the right to write the domain. Give each loop its own account when both work one domain at once, since the server notes an account’s writes in every lease it holds there.
Where a loop runs
The reflect loop runs on the domain’s Reflection home. The evolve loop on a team domain runs on its evolve home, the instance whose service.public_url the MANIFEST’s evolve_home names (on a shared database, the one of those instances that holds the domain’s host lock). Crystalline fills evolve_home in: the first scheduled evolve run at an instance with a service.public_url claims it, and its pull request carries the line. Until then every copy may run evolve, and crystalline doctor warns. A domain without a GitHub origin runs it on any instance with its own copy, and on a shared database on the instance that holds the host lock. An instance where the loop doesn’t run answers runs on <name>, is never due and refuses run. No loop runs on a read-only instance or a read-only domain.
What a run shares
On a team domain with a GitHub origin the runner shares the run’s changes as a pull request, whatever sharing says, titled with the loop, the domain and the date, with the run’s summary as its description; a second run while that pull request is open adds to it. In remote mode only the account’s own writes through the MCP tools during the lease go in. A local run shares the files new or changed since it took the lease. Either way, a file a person had unshared work on when the run started is held back and counted, the changes of a stopped run stay local and Reflection pages are never part of it. A domain without an origin, or an instance with GitHub off, shares nothing.
The lease and the run record
One lease per domain and loop, renewed every 60 seconds and taken over by the next run once it hasn’t been renewed for five minutes, which records the old run as expired. A run record keeps the start and end, the runner, the account, the outcome (done, stopped_at_cap, failed, nothing_due or expired), the counts, a short summary and the pull request; the index keeps the last 50 per domain and loop, so a full reindex loses them. After a failed run the loop waits 5 minutes before it’s due again, twice as long after each further failure in a row and at most 6 hours.
The last run in Fluid
The Reflection home says “Last reflected” and the maintenance page “Last maintained”, each with the time, the runner and the outcome, and nothing when the loop never ran. A reader sees the line; someone who may run the loop also sees the summary, the pull request and the counts. A Reflection contributor without the right to the knowledge sees no “Last reflected” line.

25.14 The provisioning decision

A domain whose MANIFEST declares a Provisioning section ships skills, commands, subagent definitions and MCP server configs, and each machine decides per domain whether to take them (Chapter 21). The decision is the registration’s provision key.

crystalline provision allow <domain>
Sets provision: true and reconciles, installing the domain’s artifacts into every harness this machine onboarded.
crystalline provision deny <domain>
Sets provision: false and reconciles, which removes whatever the domain shipped before.
crystalline provision status
Prints each domain’s decision, grouped allowed, undecided and denied and sorted by name, each harness’s installed state and the domains still waiting for one. The domains that declare no provisioning and the virtual ones fold into one line each; --all lists every one of them.
provision
The MCP tool, with action set to status, allow, deny or apply and domain for the two decisions. On an instance where agents sign in, everything but status takes the admin role.

25.15 Where domains live and which ones an agent hears about

Four more settings touch domains without belonging to one. Two sit in the global config and two are files named .crystalline.yaml, one in a workspace and one at a domain’s root.

domains_root
The folder a new file domain lands in when nobody names one; the setting is in Chapter 23.
prompt.rules
Narrows which domains the session-start briefing names for a workspace, by path pattern. The rule syntax and an example are in Chapter 23.
preferred_domains
A list in a .crystalline.yaml at a workspace’s root that puts those domains first in the briefing, in that order. It only reorders and never hides a domain.
Where the two routing hints apply
Both need a workspace path, so they shape the briefing the session-start hook prints with crystalline prompt system, while an agent connecting over MCP gets the server’s briefing, which covers every domain it may see and ignores both.
The domain’s own .crystalline.yaml
A file at a file domain’s root whose verify: block tunes the checker for that domain, with its keys and the codes they touch in Chapter 26; a virtual domain has no root, so it always takes the defaults.
NoteIn brief

A domain is a folder, a database-only virtual domain or a team domain with a GitHub origin, and each is one entry under domains: in config.yaml with path, origin, provision and review. You register one with add_domain, crystalline domain add, Fluid’s New domain dialog or an environment variable, and removing it leaves files on disk but deletes a virtual domain’s engrams, which is what --purge confirms. A domain declares its name in its MANIFEST and keeps it on every machine; a rename, from the command line, the API or Fluid, is the repair for a name that turned out wrong and leaves the old one working as an alias. Every domain starts shared, and a private one belongs to one owner and admits members as none, viewer, editor or manager; a write there takes the lower of level and instance role, while on a domain under a server’s GitHub App the role on the repository decides. Review mode is switched with crystalline domain review or in the domain’s settings in Fluid and needs a GitHub origin and a clean folder, and turning it off folds or discards each person’s drafts. Your machine can also offer a connected server’s domains beside its own, and read_only in the MANIFEST freezes a finished domain against every write through Crystalline. prompt.rules and preferred_domains steer the hook’s briefing, and a domain’s own .crystalline.yaml tunes the checker of chapter 26.