Skip to content

Automations

Mehmet Nuraydın edited this page Jul 28, 2026 · 1 revision

Automations

Every other surface in dreamcontext is something the brain knows. An automation is something the brain does: a recurring job that runs on a schedule with nobody at the keyboard, as a headless claude session with elevated permissions. That last clause is the whole design problem. A scheduled unattended run with bypassPermissions is, correctly understood, a standing grant of your machine to a prompt, so the entire subsystem ships disabled by default and stays that way until a human installs the dispatcher and approves each automation on this specific machine.

All job semantics live in the manifest's prose. There is no built-in "digest" command and no hardcoded job type. The CLI carries schedule, model, effort, and timeout; the ## Prompt section is the job. That choice keeps the surface honest: anything you can write as a prompt can be an automation, and nothing about a job is hidden inside dreamcontext where you can't read it.

One dispatcher, not one job per automation

The scheduler is a single launchd agent that wakes every five minutes and evaluates every automation across every project on the machine. Per-automation jobs would have been the obvious mapping, and they were rejected: one agent per automation multiplies the install surface, scatters the state a human has to reason about, and makes "what is scheduled on this machine?" a question with no single answer.

The non-obvious cost of that decision is PATH resolution. A script's default non-interactive shell can resolve a different installed copy of dreamcontext than the one you use every day, because the PATH setup in your shell profile applies to interactive shells and not to an unattended tick. So the dispatcher resolves the real command path once, at install time, uses that resolved path directly on every tick, and falls back to a full interactive-shell resolution only if the saved path stops working. That fallback is what makes it self-healing: if an upgrade moves the CLI, the next tick finds it again instead of silently failing forever.

One gap is accepted rather than papered over. If the fallback resolution itself hangs, nothing watches for it. What surfaces instead is automations install --check showing stale last-tick timestamps. There is deliberately no "the dispatcher looks dead" alert layered on top, because how long a legitimately busy tick may take varies with how many automations are due at once, and a flat threshold would cry wolf during healthy runs.

Automations also run one at a time, in the order they come due, never in parallel on the same tick. The consequence is worth stating plainly rather than discovering: if two automations share a fire time and one is slow, the other can be pushed past its own catch-up window, and a fire past its window is skipped rather than run late. Set the window comfortably longer than the combined timeouts of anything sharing that time.

Approval: a hash of exactly what will happen

Every automation carries a machine-local approval, computed at creation and checked before every fire: a hash over the prompt, output instructions, model, effort, timeout, output directory, and the learning switch. Change any of them and the automation is blocked until a human reviews it with automations approve, which shows what changed across all seven fields rather than the prompt alone. Reviewing only the prompt would let a timeout or an output-directory change sail past.

Two asymmetries in that list are deliberate, and both are the interesting part of the design.

learning is hashed only in the ON direction. An automation with learning off hashes exactly as it did before the field existed, so upgrading dreamcontext never re-blocks a working automation. That matters more than it sounds: a blocked run notifies nobody by design, so an upgrade that re-blocked everything would be a silent outage across every machine at once. Turning learning on does change the hash and does demand a review, because it widens what the run reads.

Schedule and shared are not hashed at all. A synced edit can move an automation from weekly to daily, or re-enable a disabled one with a generous catch-up window, without re-approval, on the reasoning that the behavior did not change, only when it happens. That is a real capability change, not a technicality, and it is documented as a limit rather than hidden as an implementation detail. The same prompt running four times as often is a bigger footprint.

What approval covers is narrower than it first reads, and the boundaries are the point:

  • It does not limit what a run may do once it starts. Approving means "I read this and I would run it myself", not "I have constrained it".
  • It does not follow references inside the prompt. A prompt that says "read this file and follow its instructions" has just created an unreviewed, unhashed instruction source. Never delegate an automation's instructions to a file someone else can edit.
  • It is machine-local, not tamper-proof. It lives in this machine's configuration, the same trust boundary as your shell profile. It exists to catch a changed manifest, whether the change arrived from a teammate over brain sync or from someone with local write access, which is why a fully private automation is gated too.

Private by default, and unsharing is not retroactive

A new automation lives on this machine only. shared defaults to false, and the read is strict on purpose: only the literal value true counts, so a missing flag, a typo, or "yes" all read as private. A security-relevant flag has to fail toward the safe state, which also means every automation written before the flag existed is private, not accidentally published.

Sharing moves three things together and never separately: the manifest, its cache, and its output. There is no way to publish the prompt while keeping the output private, because the output is usually the sensitive half, and a half-private automation is worse than an honest one.

unshare prints a warning every time it runs, because it has to. Once a manifest has been committed and pushed it is in the remote's git history and on every machine that pulled it. Removing the ignore negations stops future changes from publishing; it does not retrieve what already left. If something sensitive was shared, treat it as disclosed. Getting it out of history means git filter-repo, a force-push, and every teammate re-cloning.

The state is never left to inference. list and show report one of five states, including the three drift cases: a flag that claims shared while nothing publishes (fails safe, reported with the repair), a flag that claims private while something still publishes (does not fail safe, so it is auto-repaired the moment it is noticed), and a manifest already tracked by git despite being marked private (the loudest state, never auto-repaired, because a .gitignore entry cannot untrack a file git already has, so it names the exact git rm --cached command instead).

One door remains open by design and is guarded explicitly: sleep reads a private automation's output, because privacy governs what leaves this machine over git, not what this machine's own agent may read. Since knowledge files sync regardless, distilling a private automation's output into knowledge would republish it through a different door. So sleep done refuses to finish, lists every knowledge file involved and the automation it came from, and proceeds only with --ack-private-derivation. That flag has no shorter alias, on purpose.

The pattern: a job that gets better instead of repeating itself

A scheduled job that starts from zero every time repeats its mistakes forever, and nobody is watching it happen. So every automation carries a ## Pattern section in its own manifest, read before a run and appended to after one: a standing playbook, plus a newest-first lesson ledger capped at twenty entries. A job that hit a broken endpoint on a Sunday knows that next Sunday.

Three properties carry this design, and each is a decision rather than an implementation detail.

Every cap is enforced at write time, both count and size. The pattern is prepended to every run's prompt, so unbounded growth would quietly eat the context the actual job needs. The failure mode of an uncapped ledger is not a crash; it is a job that slowly gets worse while looking fine.

A run records a lesson through a command, never a file edit. automations learn is what makes the caps enforceable and the ledger's grammar intact, but the load-bearing reason is narrower: the command never touches frontmatter, so a run recording what it learned can never change an approval-hashed field and block itself. A learning mechanism that can lock the automation out is not a learning mechanism.

The pattern is not approval-hashed, and that is deliberate. It changes on its own every run, so hashing it would block the automation daily and train you to approve without reading, which is worse than not gating it at all. What is hashed is the switch that admits it. The consequence is that the pattern is an unreviewed input, so it is framed as one: it reaches the model as observations, ordered after the approved prompt, explicitly labelled as notes that lose to the instructions. This is the same hazard as a prompt that delegates to an editable file, with the difference that this file is bounded, written through exactly one command, and told to the model as untrusted. A run that reads something from the outside world and dutifully "learns" it is precisely the case that framing defends against.

Because the pattern lives in the manifest, it inherits the automation's privacy with no second switch to get wrong.

Reading the run that actually happened

The output document is a run's conclusion with the working thrown away, which is the wrong half the moment a run does something surprising. Every run records its claude session id, and the transcript is already on disk, so automations session <slug> replays it: turns, every tool call and its argument, which ones failed, the cost and turn count. The dashboard puts the same drill-in on every row of an automation's run history.

A missing transcript reports as "no transcript", never as an error. claude writes one only once a session has produced a turn, a run that never reached a session has no id, and nothing in dreamcontext owns that file's lifetime. Treating an absence you do not control as a fault would train people to ignore real ones.

The notification is the product surface

For an unattended job, the banner is realistically the only thing anyone reads. So the notification body is the run's own opening result sentence rather than the name of a file that was written, titled by the automation, with the output document as a click target. Success and failure sound different, so an unattended failure is audibly distinct rather than one more banner.

That has a privacy consequence worth naming: a private automation's findings appear on screen, and macOS keeps them in Notification Centre. A screen can be mirrored, projected, or simply sat behind. Two clean outs exist that do not cost you notifications: a ## Notification section telling the run what to say instead, or notify: false on that one automation.

notify defaults to true while shared defaults to false, and the opposite defaults are intentional. An over-share is a leak; a run nobody is told about is a silent loss. The two flags fail toward opposite states because their failure modes are opposite.

The notifier itself is a small app installed alongside the dispatcher, which creates a currency problem shared with the wrapper and plist: an applet rebuilt only by automations install would leave a shipped fix stranded on every already-installed machine. So the rendered script's hash is stamped into the bundle, upgrade refreshes a stale one, and install --check reports staleness loudly.

Stopping a run, and why never pkill

A running automation is its own detached process group. Killing the tick or the run command does not stop the automation underneath it, and because that command held the timeout, killing it removes the only time limit the run had. automations kill <slug> reads the exact process group recorded for that run and shows what it is about to kill first.

Doing it by hand is not a slower version of the same thing, it is a different and worse operation: a command-line search for "claude" matches other sessions on the machine, including your own interactive work, and the process list alone cannot tell them apart.

There is no automatic cleanup of a run left behind this way. An automation with an orphaned previous run refuses to start again, reported on every tick, in the session snapshot, and in show, until a human clears it. That is a deliberate trade: recovery costs a person noticing, rather than something guessing at what to kill on your behalf.

Recallable, and bounded in the corpus

Automations are a first-class recall channel, so "do we already automate this?" and "what did the nightly job find?" are recall questions rather than list-then-read-the-file questions. Three things are indexed: the manifest (so a near-duplicate is found before you create one), its ## Pattern (so a lesson one run learned is findable by a session that never ran it, which is what stops the ledger being write-only), and each run output.

The limits keep machine-generated text from crowding out curated knowledge. Run outputs are level and rank-penalised exactly like session digests, and only the thirty most recent are indexed; older ones stay on disk and readable by path. A manifest is ★★ while enabled and while disabled, so --level 2 skips paused jobs and run logs. And automations never cross a vault boundary: manifests are gitignored machine-local state, shared defaults false, and a manifest body is the prompt a bypassPermissions session runs, so cross-vault recall and federation digests both exclude them while your own vault treats them as first-class.


Part of the dreamcontext deep dive — Home · README

Clone this wiki locally