Skip to main content

14  The loops

This chapter is about keeping a domain’s two loops running when nobody opens a session: what a run does on its own, how you start one on your own machine and how a team sets one up on its shared instance. Crystalline has no model of its own, so somebody has to come by (Chapter 12), and here that somebody comes on a timer.

14.1 The heartbeat

Every domain has two lists in Crystalline. The reflect queue holds what the Reflection needs (a person’s change to weigh, a thread waiting for an answer, a page whose engrams moved), and the sweep holds what the knowledge needs (Chapter 10). Neither list does anything by itself: work leaves it only when an agent takes it, and in a session that’s when the reminder comes up and you say yes.

The loop is the heartbeat of the Reflection. It beats on a schedule you pick (every few minutes, every night, every Sunday), and each beat asks one question: is anything due? Most of the time the answer is no, and the beat ends right there. No agent starts and nothing is spent. When something is due, the beat wakes an agent, which works the list and goes back to sleep. Keep it beating and the question about Lambert’s change is in her thread the same evening. Without a loop, the domain looks fine for a week, and then you notice the threads getting older and the pages still naming last month’s pick. Fluid shows the pulse: the Reflection home says when the domain was last reflected, by which runner and how it ended, and the maintenance page does the same for the sweep.

If you write software, the loop most closely resembles a Kubernetes reconcile loop. The engrams and the pact are the desired state, the pages are the actual state and the queue is the difference. The loop works the difference until none is left, and it never assumes it’s done just because it ran. Most engineers know the event loop better, and that picture works too: the queue holds the events, the beat is the tick and the agent is the handler. The picture breaks in two places: nothing spins between beats, and taking an item doesn’t consume it (the queue is computed fresh each time, so an item stays until the agent has really settled it).

14.2 Two loops, one shape

Figure 12.2 showed the two lifecycles of a domain, and each gets a loop of its own. The reflect loop works the reflect queue: it weighs what people changed, answers threads and brings changed engrams into the pages. The evolve loop works the sweep, but only the mechanical findings, the ones that complete intent the archive already records.

For both, the runner first asks whether anything is due and ends at once when nothing is. Otherwise it takes the lease, so only one run of a loop works a domain at a time. It starts an agent with a fixed set of instructions and lets it work up to a cap (twenty items by default), then shares what changed and records how the run ended. What the agent doesn’t reach stays due for the next beat.

The reflect loop runs only where the domain’s Reflection lives. Every other instance says where it runs and never starts it, so five teammates with the same team domain don’t start five reflect runs. A team domain runs its scheduled evolve loop in one place too, its evolve home: the first scheduled run on an instance with a public address claims it, and that run’s pull request carries the claim to the team. Every other copy names the home and refuses a scheduled run, and the sweep in a session still works everywhere. A domain only you keep runs evolve wherever it lives.

14.3 What a run does without you

A run has nobody to ask, and its instructions say so: the agent never waits for a yes. When it needs a person’s answer, it asks where people will see it: the reflect agent opens a thread on the page, and the evolve agent names its question in the run’s summary. Anything that needs a yes is left for a person: a judgment finding, a delete, a move, a page a person wrote. The tools for deleting, moving, splitting and sharing are switched off for the run, and its instructions rule out the rest. An item no run can settle isn’t picked up again until it changes (a new message in its thread, a new edit), so a question waiting for Lambert doesn’t start a run every five minutes all day.

A run also reads only the domains everyone who reads its own domain may read, and it writes only into its own domain. Anybody who may comment on a page can ask a question in a thread, and the run that answers can’t draw that answer from a private domain the page’s readers may not open (Chapter 20).

On a team domain the run’s engram changes arrive as a pull request, never as a direct share, whatever the domain’s sharing setting says. That’s the same process a person goes through (Chapter 17): an agent working alone at night makes mistakes like anybody else, and the team reviews its work in the morning. The pull request carries only what the run wrote, and a file somebody was still working on is held back for that person. Reflection pages are never part of it (the domain’s home keeps them and backs them up as always).

A run that works ends with a short summary, and in the club it reads like this:

Done: 4
Left for a person: 1
Errors: 0
Thread on the Dune page: asked Lambert what moved Dune
from March to May. Left open for her.

A run that fails doesn’t hammer the domain: the loop waits longer after each failure in a row, and the first run that works ends the wait.

14.4 On your own machine

Your own machine is the entry level, and you have three ways to keep the beat:

  • a session now and then, where you say yes when the reminder lists what’s due (you know this one already);
  • a scheduled run, once a night or once a week, that does the same thing without you;
  • a watcher that checks every minute and runs as soon as something is due, handy when people wait for answers.

A run on your own machine starts Claude Code as its agent (so Claude Code has to be installed) and acts as you. It can’t tell its own writes from yours, so don’t edit the domain on that machine while a run works it. On a machine only you use, that’s rarely a problem: the run happens at 6:30 on a Sunday, and you’re asleep. While it works, your own sessions on that machine read and write the way the run does, and every answer says so in one line.

NoteIn practice

Your books domain keeps growing, and the sweep you keep meaning to run hasn’t happened in months. You ask your agent to make it happen on its own: every Sunday morning the evolve loop should look at books and fix what it may fix. It sets up the schedule and runs the loop once to check, and the first run finds three links that point at titles you spelled differently in the reading log and fixes them.

The run leaves “Wait for the paperback” alone, although its review date has passed: whether the rule still holds is a judgment, and only you can make it. The next time you open a session, the sweep’s reminder brings it to you as a question. Most Sundays after that, the run is over within a second, since nothing’s due.

TipUnder the hood

crystalline evolve run --domain books and crystalline reflect run --domain books run one beat: they check what’s due, take the lease, start claude with the shipped prompt and only the Crystalline MCP server, share, record and release the lease. claude has to be on the PATH of the run, and Claude Code’s own installer puts it in ~/.local/bin, so a crontab names the folders:

PATH=/usr/local/bin:/usr/bin:/bin:/home/you/.local/bin
30 6 * * 0 crystalline evolve run --domain books >> /home/you/crystalline-evolve.log 2>&1

crystalline reflect watch --domain books is the watcher, a long-running process for a login item or a systemd user unit, and crystalline <loop> status shows what’s due. A local run needs the daemon, and on a team domain it shares the files that changed since it took the lease, minus what you hadn’t shared when it started. The flags, the exit codes and the run logs are in Chapter 25.

14.5 On a shared instance

On a shared instance the loops are a job for whoever runs the server, and it’s the advanced case: the run needs an account of its own, a place to run and, if you like, a way for the server to call it. Remember the night Lambert changed the Dune date in Chapter 12: the agent that asked her about it came from such a run.

The run gets an agent account of its own (Chapter 19), with no password and one token, and the account needs the right to the loop it runs: curating the Reflection for the reflect loop, writing the domain for the evolve loop. Give each loop its own account when both work one domain at the same time, so each pull request carries only its own run’s changes. On a domain whose access follows GitHub (Chapter 19), the account gets its rights the way a person does: an admin links it to a GitHub user of its own, a bot with write access on the repository, and that covers either loop, since a GitHub writer also curates the Reflection. Making the run’s account an admin works too, but it’s far more than a loop needs. The runner talks to the server as that account, and so does the agent it starts. That’s how the server tells the run’s writes from everybody else’s, and what a person edits in Fluid during the run never ends up in the run’s pull request.

The runner itself can be almost anything that starts a program on a schedule: a systemd timer on the server, a Kubernetes CronJob, a scheduled GitHub Actions job for a team without a cluster or an n8n workflow that brings its own agent. A runner that polls every five minutes ends within seconds when nothing is due, so polling is cheap. If you’d rather not poll at all, the domain switches a trigger on and the server’s operator gives it an address, and the server calls a runner that listens there (an n8n workflow, say) when work becomes due. That’s within a minute when a person is waiting for an answer, and after half an hour of quiet when only pages need updating (so a bulk import sends one call, not hundreds).

On the club’s server (the one you look after) both loops run, each under its own account. The reflect loop started as a nightly timer, and that’s the run that asked Lambert about Dune. These days the timer fires every five minutes, and a question about a changed date usually waits in the thread before its author gets home. The evolve loop runs once a week, and its first pull request also carried one line for the MANIFEST, which made the club’s server the domain’s evolve home. On Monday Dallas finds a pull request titled “Evolve run on club” with the date and the run’s summary as its description, two fixed links and one wired relation, and merges it with his coffee. The club’s Reflection home says “Last reflected 4 minutes ago by timer: done.” and, beside it, that one item is waiting for a person. A member who reads the knowledge sees that the loop ran, Lambert doesn’t see the line at all, and someone who may run the loop also sees the summary and the pull request.

TipUnder the hood

In remote mode a runner needs nothing but the crystalline binary, Claude Code with its Anthropic credential and two variables: CRYSTALLINE_REMOTE_URL names the server and CRYSTALLINE_REMOTE_TOKEN (or CRYSTALLINE_REMOTE_TOKEN_FILE) carries the cmt_ token of the agent account, which crystalline users add <name> --role editor --mcp-token <label> creates with the account. Under a GitHub App an admin links that account on the server with crystalline connect github --personal --as <name> (in a container through docker exec) and confirms the code while signed in to GitHub as the bot, a collaborator with write access on the repository. A GitHub user links to one account only, so the two loops need two bots, and on a paid organization plan each takes a seat on a private repository. The runner then speaks to the server’s control route as that account, and the agent reaches the server’s MCP endpoint with the token as a bearer header. --runner labels the run, so Fluid says “by timer” or “by k8s”. The reflect loop runs against the domain’s Reflection home. On a team domain the evolve loop runs against its evolve home, the instance whose service.public_url is the MANIFEST’s evolve_home: the first scheduled run on an instance with a service.public_url writes the key, and that run’s pull request carries it to the team. Every other copy says runs on <home> and refuses a scheduled run, while evolve_engrams and crystalline evolve in a session work everywhere. On a shared database the home is asked first, then the host lock. A domain only you keep has no evolve home. A runner that reaches the server by a name people don’t use, a cluster service name for example, needs that name in the server’s CRYSTALLINE_SERVICE_ALLOWED_HOSTS. A systemd service for the club’s reflect loop runs crystalline reflect run --domain club --max-items 20 --runner timer with SuccessExitStatus=1, since a run with nothing due exits 1, and a timer starts it. The crystalline repository’s examples/ folder has a runner image and CronJobs for Kubernetes, a GitHub Actions workflow and n8n workflows; for a runner that starts its own agent, crystalline <loop> prompt --domain club prints the shipped prompt.

A domain asks for the call with its trigger keys, and the operator sets the address and the secret. The body carries counts, never content, and is signed with HMAC-SHA256 over the timestamp and the body, so the receiver checks that the instance sent it. The full guide, with the signature check in a shell and in n8n, is the crystalline repository’s docs/automation.md. The keys are in Chapter 24 and the settings in Chapter 23.

NoteIn brief

Crystalline keeps the lists, and the loops make an agent come by when nobody opens a session: the heartbeat of the Reflection, which most of the time finds nothing due and costs nothing. The reflect loop keeps the pages in step with the engrams, and the evolve loop fixes the mechanical findings of the sweep. A run never waits for a yes: it asks people in a thread or names the question in its summary, leaves what needs a yes for a person and, on a team domain, delivers its changes as a pull request. On your own machine a scheduled run or a watcher acts as you; on a shared instance a runner works as an agent account of its own, on a schedule or called by the server’s webhook.