Originally published on mihaibuilds.com. Cross-posting here because dev.to is where I read a lot of work like this myself.
Three weeks ago I shipped the second milestone of The Brain — the scheduler daemon, cron triggers, workflows that read their previous run, an opt-in HTTP endpoint. That was M2. The Brain could run unattended on a clock.
Today M3 is done. The Brain now reacts — to HTTP requests, and to filesystem changes.
Why this matters
M2 made The Brain worth running unattended on a schedule. M3 makes it react to things that happen. The hardest, most useful workflow automations are the reactive ones — the workflow that fires when a customer signs up, the workflow that processes a file the moment it lands on disk, the workflow that wakes up because another system has news.
M1 was the runner. M2 made the runner unattended. M3 makes the runner reactive. M1 + M2 + M3 together is the trigger surface most people actually need.
What M3 ships
Webhook triggers. Register any workflow as a webhook endpoint. The Brain prints a secret once, you save it, and from that moment on, any HTTP caller with the secret can fire the workflow over the network.
docker compose exec brain brain register-webhook examples/webhook_handler.py
The CLI prints the HMAC secret exactly once — same caller-side-storage discipline as a GitHub personal access token. There's no brain show-webhook-secret command by design; if you lose the secret, you unregister and re-register to issue a fresh one.
Fire it from anywhere that can compute an HMAC signature:
SECRET=<your-saved-secret>
BODY='{"hello":"world"}'
SIG="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')"
curl -X POST http://localhost:8001/webhook/webhook-handler \
-H "X-Brain-Signature: $SIG" \
-H "Content-Type: application/json" \
-d "$BODY"
The X-Brain-Signature: sha256=<hex> header convention is identical to GitHub's X-Hub-Signature-256 — so existing webhook senders work without translation. The endpoint runs the workflow synchronously and returns the run metadata. Wrong signature is 401. Unknown workflow name is 404, same shape as a disabled webhook, so existence is not leaked through the response code.
File watcher triggers. Register a workflow to fire when something changes on disk. The Brain runs a separate watcher daemon that observes the directory and fires the workflow on filesystem events.
docker compose exec brain-watcher brain register-watcher examples/markdown_watcher.py \
--path /data/watched --events modified
--events accepts any combination of created, modified, deleted. The watcher daemon picks up the new registration on its next 10-second sync. A 500ms debounce per (workflow, path) coalesces multiple filesystem events from a single editor save into one workflow run.
The watcher runs in its own container behind the watcher compose profile. If the watcher crashes, the scheduler from M2 keeps running. If the scheduler crashes, the watcher keeps watching. Isolation by container, not by retry loops.
A trigger placeholder family. Workflows triggered by a webhook or file event can read the inbound payload via a new placeholder family:
ShellStep(
name="received",
command="echo got event={trigger.event} body={trigger.body}",
)
Four placeholders are available wherever string substitution works: {trigger.event} (the trigger mechanism), {trigger.body} (the inbound body — parsed JSON stringified deterministically, or raw string fallback), {trigger.headers.X} (case-insensitive HTTP header lookup, allowlist gated), {trigger.path} (the file path for file-triggered runs). Referencing {trigger.X} on a workflow you ran manually fails the step with a clear error — same strict-failure shape as M2's {previous.X} placeholder.
The four classical trigger types — manual, cron, webhook, file — are now all there. Same workflow model. Same persistence model. Same brain history and brain show view of every run.
Architectural decisions worth naming
HMAC verification is constant-time at every failure path. Wrong prefix, wrong algorithm, malformed hex, length mismatch, non-string input — every failure shape runs through the same hmac.compare_digest call against a placeholder digest. There is no early len(a) != len(b) branch. The verifier is pinned by an end-to-end timing-attack regression test: it measures wall-clock variance between "wrong-length" and "right-length-wrong-value" failures across 2000 iterations and asserts the ratio stays under 10x. If a future refactor introduces a length-check shortcut, the test breaks loudly.
404 on unknown webhook is a locked v1.0 behavior, not a bug. A probe CAN distinguish "unknown webhook" from "known but wrong signature" via response code. The webhook name is not a secret in this threat model — single-token-server-to-server with known callers, and if you can list webhooks you already have privileged access. Pinning the lock as a regression test: any future refactor that adds a constant-time-equal-lookup must break the test and surface as an explicit architectural decision, not a silent change. Same lock applies to the 404-for-disabled case.
Watcher and scheduler heartbeats coexist via daemon_id suffix. Both daemons UPSERT into the same daemon_heartbeats table. The scheduler uses the container hostname as its daemon_id. The watcher appends :watcher. The crash-recovery sweeps are mutually disjoint via the trigger_context->>'event' JSONB filter — the scheduler clears running rows broadly, the watcher clears only file-triggered ones, and the two queries never overlap. Both daemons can be in the table at once without collision.
The 500ms debounce is in-memory. A dict[tuple[workflow_name, path], float] keyed by monotonic time. Lost on daemon restart, which is fine because crash recovery re-fires from current FS state and any in-flight transient state is by definition stale. The boundary is exact and pinned at three layers: the unit _should_fire function, the module constant DEBOUNCE_SECONDS == 0.5, and an audit-pass test that pins 499ms blocks, 500ms fires, 501ms fires.
Sequential within process, concurrent across processes. Two webhook calls to the same workflow queue inside the API process. Two file events to the same watcher queue inside the watcher process. But the API, scheduler, and watcher daemons all run workflows in parallel because they're separate processes against the same database. No global work queue. The database absorbs concurrent INSERTs at the workflow_runs row level.
The {trigger.body} resolver stringifies JSON deterministically. json.dumps(body, sort_keys=True, separators=(",", ":")). So the same parsed payload produces the same substituted command every time — deterministic for cache invariants, deterministic for diff workflows. Raw string bodies pass through unchanged. Nested JSON access ({trigger.body.foo}) is NOT supported in v1.0 — the body is a string after serialization; body.foo is treated as an unknown trigger field. Pinned with a locked-behavior test.
Header allowlist is hardcoded, not configurable. The four placeholders the workflow can read from {trigger.headers.X} are bounded by the allowlist: content-type, user-agent, x-github-event, x-github-delivery, x-stripe-event, x-event-key. Authorization, X-Brain-Signature, cookies, infrastructure headers — never exposed to the workflow. If a step needs another header, that's a workflow-step concern that should be visible in the workflow source, not a configuration knob.
What v1.0 won't do, on purpose
The watcher daemon is not highly available. One watcher per host, same single-daemon-per-host invariant as the scheduler. Two watchers running in parallel would clobber each other's crash-recovery logic.
No nested JSON access via {trigger.body.foo}. Locked. The body is a string after serialization; body.foo is treated as an unknown trigger field. If you need to pluck a field, do it in the workflow step (e.g. echo {trigger.body} | jq -r .foo).
No recursive directory watching. Single directory per watcher row, no globs. If you want to watch a tree, run multiple watchers. The hardest part of watcher correctness is bounding the work; bounding it explicitly via one-dir-per-row is the v1.0 choice.
No webhook API docs in production. /docs, /redoc, /openapi.json all 404 by design. The threat model is known-callers — anyone enumerating the endpoint shape is in scope.
No replay protection on webhooks. Idempotency is the workflow's concern, not the transport's. If your workflow can't be replayed safely, build the idempotency key check into the workflow itself.
No catching up on missed file events. Filesystem events are not persistent. The watcher daemon sees current FS state at boot; events that happened during downtime are missed. Don't use file watchers for anything where missing events is unacceptable — use a cron schedule that reconciles state instead.
Workflows still execute one at a time per process. Cross-process concurrency exists (API + scheduler + watcher in three containers can all run workflows simultaneously). Within-process concurrency is sequential by design for v1.0.
These are deliberate trade-offs. M3 is the smallest correct reactive trigger surface, not the most ambitious one.
Who this is for
Same audience as M1 + M2, with one addition: anyone building self-hosted automation against webhook senders (GitHub, Stripe, your own dashboards) who's tired of either rolling their own webhook server with no run history, or paying for a managed orchestrator that owns their auth.
If you've ever wired a webhook to a tiny Flask app that calls a script and then forgotten about it for six months until something breaks — this is for you.
What's next
Milestone 4 adds MCP tool calling as a step type, plus a pluggable LLM provider abstraction. That's the milestone where The Brain becomes ecosystem-aware — any MCP server in your environment becomes a callable step, not just Memory Vault. Each milestone gets a dev-log post here as it ships — one of four dev.to posts across the build period.
Try it
git clone https://github.com/MihaiBuilds/the-brain
cd the-brain
THE_BRAIN_API_TOKEN=any-value docker compose --profile api --profile watcher up -d
# register a webhook (saves the secret to stdout — copy it now)
docker compose exec brain brain register-webhook examples/webhook_handler.py
# register a file watcher (must run from inside the watcher container)
docker compose exec brain-watcher brain register-watcher examples/markdown_watcher.py \
--path /data/watched --events modified
# see all your triggers in one place
docker compose exec brain brain list-triggers
Drop a file in ./watched, sign and POST to http://localhost:8001/webhook/webhook-handler, and the runs land in brain history alongside any manual or scheduled runs from M1 and M2. The repo has the longer version with the full HMAC signing recipe, the trigger-placeholder reference, and the lifecycle commands for both trigger types.
Follow along
- Twitter / X: @mihaibuilds
- Blog: mihaibuilds.com
- GitHub: github.com/MihaiBuilds/the-brain
Top comments (0)