Ruby · Rails or CLI · PostgreSQL or SQLite · MIT
Not documents you hope someone reads. Recipes as data, per-subject runbooks, stable names for rituals that outlive any record, and per-run evidence — including who or what acted — so “did last night’s run actually complete?” is a query.
State tells you what is done. Jazari runs also record the opaque actor_ref — a human, agent, or system job — and attach that identity to the evidence. Explicit actors win; trusted defaults and run inheritance keep automated procedures simple without losing attribution.
Where this came from
Four MCP servers in Ruby and Rails, and the first one was built the way everyone builds their first one: as another REST API. One tool per endpoint.
That surface grows the way an API grows, except an agent has to read all of it before deciding anything. It had to be reconciled — a sprawl of endpoint-shaped tools collapsed down to roughly fifteen, grouped by what someone is actually trying to accomplish.
The lesson is not "fewer tools". It is that MCP is an adapter, not a layer. Business operations, queries and authorization belong in shared services; REST and MCP are both thin skins over them. REST stays resource-oriented. MCP should be a small set of task-oriented tools shaped around an agent's goal.
Which makes MCP feel closer to a standardised RPC — or an old SOAP-style service contract — than to REST. That is fine. What is not fine is letting it quietly become a second service layer with its own copy of the rules.
The MCP surface mirrors the routes file. Every new endpoint is a new tool. The agent reads a catalogue of verbs and has to assemble the intent itself.
“what can I call?”The MCP surface mirrors intentions. Tools group around outcomes, and both adapters call the same service — so authorization cannot drift between them.
“what am I trying to do?”Working this way across four projects changed a different question entirely: why does operational knowledge live permanently inside a repo? Why is deploy.md a file, next to the code, in one repository, when the procedure it describes governs a fleet?
A markdown runbook is a fine thing to read and a useless thing to operate. It has no state, no evidence, no revision guard, and no answer to “is anyone doing this right now?” Committing it harder does not fix any of that.
Bolt items onto the record — a deploy, a site, an app. Real progress: state becomes queryable. This is where most systems stop.
solved: current stateOnboarding. Incident triage. The weekly review. There is no row to hang them on, so they fall back to prose — the exact problem, returning.
unsolved: everything elseA queue is a ritual addressed by name, needing no record at all — and read-only by construction, because a ritual has exactly one editable home: its recipe.
“run incident-triage”A checklist you reset to run again has erased the proof it ever ran. Runs make each execution its own object, with ticks, evidence and an outcome.
“did last night complete?”Where it landed, in one line: rituals live in jazari; the vocabulary stays a registry, because a lexicon is not a procedure; and resume becomes subject-scoped by composing resolve against the canon. The host names what a thing is and points at a recipe id. Nothing else crosses the boundary — the gem never learns your fleet, and you never wait on a gem release to fix a step.
The problem
Four questions an attached checklist cannot answer — and the one that matters most is the one it destroys when you reset it.
The usual fix is a checklist attached to a record. That helps, then runs out: some procedures belong to no record at all, and a checklist you reset to run again has just destroyed the evidence it ever ran.
The mechanism
Each answers a question the one above it cannot. Most systems stop at the second.
How this ritual is done. Data, not code — operator-editable at runtime and digest-versioned, so fixing a procedure is a write, not a deploy.
“how is this done?”How this record differs. Materialised on first edit; reading a default writes nothing at all.
“how does THIS one differ?”For rituals that belong to no record. The name is the contract, so whatever carries the truth today can move without breaking the call.
“what do I call it?”Who, when, which ticks, what evidence. The layer most systems skip — and the one that makes the opening question answerable.
“did it actually happen?”In use
Seed a procedure. Address it by name. Open a run, work it, attach what you saw.
# Your content, never the gem's — it ships zero recipes. Jazari::RecipeRegistry.seed!([ { id: "backup.verify.v1", topic: "Prove a backup by restoring it", description: "## Purpose\n\nA green schedule is not a verified backup.", run_policy: "once_per_calendar_day", checklist: [ { id: "dump", text: "Dump to scratch" }, { id: "restore", text: "Restore into a throwaway database" }, { id: "counts", text: "Compare table and row counts" } ] } ]) # seed! is create-if-missing — reseeding never overwrites an operator edit. # YAML/JSON files are seeds; drift is reported, never overwritten. entries = Jazari::RecipeFiles.load("config/recipes") Jazari::RecipeRegistry.seed!(entries) Jazari::RecipeFiles.drift(entries)
# A queue: a stable name for a ritual that belongs to no record. target = Jazari::QueueTarget.new( queue: "backup-verify", public_reference: { kind: "queue" }, recipe_id: "backup.verify.v1" ) Jazari.resolve(target: target).progress #=> { done: 0, total: 3, percent: 0 } # Or attach to a record you own — you authorize first, always. Jazari::RecordTarget.new(runbookable: site, public_reference: { kind: "site", site: site.slug }, recipe_id: "site.maintenance.v1")
result = Jazari.open_run(target: target, actor_ref: "agent:nightly") run = result[:run] Jazari.tick(run: run, expected_revision: run.lock_version, item_id: "restore", done: true, actor_ref: "agent:nightly") Jazari.attach_evidence(run: run.reload, expected_revision: run.lock_version, item_id: "counts", kind: "count", value: "4211 rows", actor_ref: "agent:nightly") Jazari.close_run(run: run.reload, expected_revision: run.lock_version, outcome: "completed") # Every mutation carries the revision from the read before it. # Mismatch raises revision_conflict instead of overwriting.
Jazari.last_run(target: target).outcome #=> "completed" # resolve carries it too, so ONE read answers the question: Jazari.resolve(target: target).last_run #=> { id: 4412, outcome: "completed", open: false, # started_at: 2026-08-10 03:00:04 UTC, finished_at: ... }
gem "jazari", "~> 0.5" bin/rails generate jazari:install bin/rails db:migrate # One migration, copied deliberately. Jazari never auto-appends # migrations — a shared table appearing in someone's next # db:migrate unasked is how a gem loses trust in a fleet.
The escapement
Verifying a backup should happen once a day. Triaging an incident may happen five times. So there is no global rule — each recipe declares its own.
# the recipe decides, not the system run_policy: "unrestricted" run_policy: "once_per_calendar_day"
The index COALESCEs the nullable polymorphic subject — otherwise queue runs are unconstrained entirely, because NULL != NULL.
And the day is UTC via timestamptz, so one nightly ritual cannot land on two different days depending on which region’s machine called it.
A checkbox can say what is done. A run also says who did it. Pass an opaque actor_ref for a human, agent, or job. Explicit identities win; trusted system jobs may inject a default when a run opens, and later ticks or evidence inherit the run actor when omitted.
# explicit identity for a human or agent Jazari.open_run(target: target, actor_ref: "user:42") Jazari.attach_evidence(run: run.reload, expected_revision: run.lock_version, item_id: "restore", kind: "note", value: "verified", actor_ref: "user:42") # opt-in default for a trusted system job Jazari.configure { |c| c.actor_ref = -> { "system:nightly-backup" } }
Fitting it to your machine
The MCP layer is optional — the dispatcher is not even loaded unless you ask for it. If you already have a tool, absorb the actions into it and keep your own dispatch, envelope, naming, and permissions.
The descriptors are the single source of truth: the shipped handler validates against the same list, so a published schema and the implemented behaviour cannot drift.
frag = Jazari::Mcp::Actions.schema_fragment MY_TOOL[:properties][:action][:enum] += frag[:enum] MY_TOOL[:properties].merge!(frag[:properties]) # then dispatch to Jazari.* yourself a = Jazari::Mcp::Actions.fetch("reset") a.effect #=> :destructive a.confirm? #=> true
The name
Ismail al-Jazari (1136–1206) was an engineer at the Artuqid court in Diyarbakır. He built programmable automata — a hand-washing machine that offered you a towel, a clock driven by a water wheel, pumps with the earliest known crankshafts.
That is not why the gem carries his name.
runbook was already taken on RubyGems — by a DSL for executing procedures, last released in 2021. The name is occupied; the project is not one to build on.
He also wrote The Book of Knowledge of Ingenious Mechanical Devices, finished the year he died: fifty machines, each with numbered construction steps and drawings detailed enough that a stranger who had never met him could rebuild the device — including, in his own words, the steps he had gotten wrong first.
Eight hundred years later, people have built working machines from those pages.
A procedure is not lore in someone’s head or prose in a document nobody opens. It is a written, versioned, checkable artefact that somebody else can execute — and prove they executed.
Scope
Jazari holds the state of a procedure. It does not SSH anywhere, shell out, or run your commands — execution is served by whatever you already have. What none of them keep is a durable, addressable record of which procedure ran, by whom, and what came back.