Majlis is a personal council of AI advisors — an orchestrator you talk to, a roster of named specialists it delegates to, and a pipeline that hires a new specialist when nobody on the roster fits. It runs inside Claude Code and ships as Markdown, a charter and two shell scripts.

The personas are the part people notice and the least interesting thing about it. What made this worth building, and worth writing about, is the constraint underneath: a system like this only earns its keep if it can see your actual life — your lab results, your account balances, the things you have not told anyone. And the moment that data exists in one place, you have built the highest-value target in your personal threat model and handed a key to a non-deterministic process.

The design answer is that there is no key to hand over.

62tables under one schema
116triggers enforcing it
0agents that can open the database

Why a council, and not another coding agent

Nearly every subagent kit for Claude Code is built to ship software: code reviewers, test writers, git worktree wranglers, PR summarisers. That is where the tooling energy has gone, because that is where the immediate ROI is legible.

The gap is everything else. The parts of a life that are genuinely hard to run well — career direction, health, money, routines, the follow-up you keep meaning to send — have the same properties that make software projects hard. They have state that outlives your attention. They need review cadences. They punish you for optimising the visible metric. And they are exactly the domains where you would never let a language model act unsupervised, because the failure modes are not a failing test but a wrong decision you act on for six months.

So: a council. Not because “team of agents” is a good demo, but because the alternative — one general-purpose assistant with your whole life in its context — is the design that cannot be made safe or accurate. A single agent holding career, health and finance at once has no scope to be sharp within, no boundary to enforce, and no meaningful way to tell you which of its claims it actually checked.

The name is load-bearing

Majlis (مجلس) is Arabic for “a place of sitting” — a council chamber where advisors gather and where, traditionally, one person holds the floor and the others are called on.

That last part is why the name stuck rather than being decoration. The architecture has exactly one seat you speak from. You address the orchestrator; the orchestrator addresses the council. You never talk to a specialist directly, and the orchestrator never does the work itself. It is a real constraint borrowed from a real social form, and it is enforced in the charter as a prime directive rather than suggested as a convention.

Architecture

Three bands. Top: the owner asks Jarvis, which delegates to five named specialists. Middle: the hiring pipeline — no fit, then Researcher produces an Expertise Brief, then HR Lead designs the persona, then the new specialist joins the council. Bottom: four substrate cards — Team's Inbox as intake, Owner's Inbox as outbox, Team's brain as per-member working memory, and knowledge.db encrypted with SQLCipher.
The whole system is a charter, seven agent files, six skills and two shell scripts. There is no runtime — the council is a convention the orchestrator enforces.

Three things in there are worth more than their line count.

The orchestrator cannot do the work. Jarvis routes, synthesises and reports, and is explicitly forbidden from writing the code, the plan or the research. This sounds like a limitation and behaves like a forcing function: because the orchestrator cannot quietly absorb a task it is under-qualified for, every request either finds an owner or exposes that no owner exists.

There is a second directive I did not expect to matter as much as it does. The orchestrator digests and never forwards — it must strip a specialist’s answer to the decision and the action, and copy-pasting a member’s output into the conversation is a stated failure. Verbosity is pushed down to the member level and into the written deliverable. Without that rule the orchestrator degenerates into a relay that makes every answer longer than if you had asked the specialist yourself.

The roster hires its own gaps. When a request needs expertise nobody has, Researcher first establishes what a strong human in that role actually does — competencies, tools, methodologies, vocabulary, deliverables, quality bar — and returns an Expertise Brief. HR Lead then designs the persona from that brief, writes the agent file, and registers it. The rule that makes this more than a prompt generator is that HR Lead is not allowed to invent the skill set: the persona is grounded in the research, so a new advisor mirrors a real role rather than the model’s average impression of one.

State survives a dropped session. Two inboxes handle intake and hand-off in the owner’s own frame of reference — Team's Inbox/ is where you drop work, Owner's Inbox/ is where finished deliverables land. Alongside them, Team's brain/<member>/ gives each member private working memory. That folder exists because long tasks get cut off by session limits, and a member resuming work should be able to find its own half-finished reasoning instead of starting again.

The part that is actually a security problem

Ask what has to be in the database for any of this to be useful and the answer is uncomfortable fast. Lab results with their reference ranges. Body metrics. Meals and supplements. Account balances, holdings, transactions, net worth snapshots, an investment policy. A personal CRM of who you know and when you last spoke. A journal.

Two of the five domains in the schema are marked [SENSITIVE] in the source, and that marking is not decorative — it is the reason the encryption decision went the way it did.

A dashed vertical trust boundary. Left, what an agent may do: read schema.sql (DDL only, no data or secrets), write a staged .sql file, run validate_staged.sh against a throwaway plaintext database, read inspect.sql output. Right, what only the owner may do: hold the passphrase, open a keyed session, apply a staged file, destroy the plaintext backup.
The passphrase never crosses the line — not into a file, not into an env file in the repo, not into an agent’s context.

Whole-database encryption, not selected columns

The obvious design is to encrypt the sensitive columns and leave the rest queryable. I did not do that, for three reasons.

The first is that column-level encryption forces you to classify correctly and permanently, up front. Get it wrong once — a free-text notes column on an otherwise boring table that ends up holding a diagnosis — and you have a plaintext leak with no signal that anything is wrong. Whole- database encryption has no classification step to get wrong.

The second is that the metadata is the sensitive part more often than the values. A lab_results table that is encrypted per-value still tells an attacker how many tests you ran, when, and how often you retested. In a personal dataset that shape is the finding.

The third is that partial encryption breaks the thing that makes the store worth having. There is one unified FTS5 index across journal entries, meetings, people, projects, goals and document OCR, and one relations graph linking any entity to any other. Search and backlinks over encrypted columns either do not work or require you to build a plaintext shadow index — which is to say, an unencrypted copy of the sensitive data with a reassuring name.

So: SQLCipher, the whole file, including the boring tables.

Agents never open the database

This is the design decision I would defend hardest, and it is the one that makes the rest safe.

An agent does not get a connection. It does not get the passphrase, a prompt for the passphrase, or an environment variable containing it. What an agent produces is a staged .sql file that the owner applies later in a keyed session.

Seven steps in two rows. Row one: an agent stages a key-free single-transaction file with no DDL or DELETE; a throwaway plaintext database is rebuilt from schema.sql; the file is applied twice under .bail on with a full dump after each; the two dumps are diffed and identical means idempotent means PASS. A connector labelled PASS required routes to row two: the owner reviews, a keyed session applies it, and inspect.sql verifies afterwards.
The validator never touches knowledge.db. It rebuilds the structure from schema.sql, so a phantom column fails in a scratch file against no data at all.

Every staged file has to satisfy seven rules, and the interesting ones are not the obvious ones:

  • Check schema.sql first, never guess a column. The canonical DDL is regenerated from the live database after every migration, so it always reflects reality. This exists because an agent confidently inventing a plausible column name is the single most common way this kind of system produces a broken write.
  • Key-free. No PRAGMA key, no secret, ever, in the artefact.
  • Guarded pre-flight. Verify the preconditions the change depends on and RAISE(ABORT, …) with a readable message if they are unmet, before anything mutates.
  • One transaction, so a failure mid-file cannot leave half a change applied.
  • Idempotent — a second run is a no-op. INSERT OR IGNORE, UPDATE … WHERE <not-already-done>, NOT EXISTS guards on link rows.
  • No DDL and no DELETE in a routine data file. Schema changes are one specialist’s lane; destructive removals need their own deliberately-reviewed file.
-- --- GUARD PRE-FLIGHT: verify preconditions; abort cleanly if unmet ----------
SELECT CASE
  WHEN (SELECT COUNT(*) FROM people WHERE id = 42) = 0
    THEN RAISE(ABORT, 'PRECONDITION FAILED: people.id=42 not found — nothing applied')
END;

BEGIN;
-- scoped so a second run matches nothing
UPDATE people
   SET relationship_strength = 4
 WHERE id = 42
   AND (relationship_strength IS NULL OR relationship_strength <> 4);
COMMIT;

The validator is the part that makes it real

A protocol nobody checks is a comment. validate_staged.sh is the gate, and the way it proves idempotency is the bit I like:

database/validate_staged.sh database/add_meeting_followups_20260901.sql

It builds a throwaway plaintext SQLite database from schema.sql, optionally seeds a fixture so guarded update paths are actually exercised, then reads the staged file under .bail on and takes a full .dump. Then it applies the same file a second time, dumps again, and diffs the two. If the dumps are identical, the file is genuinely re-runnable. If they differ, it says so and shows what changed.

That second application is the whole trick. “Is this idempotent?” is a claim you cannot verify by reading SQL carefully — you verify it by running it twice and comparing state. Doing that against a scratch database costs nothing and turns an intention into a property.

It also means a phantom column fails in a temp directory rather than at 11pm against your real data, which is the difference between an annoyance and an incident.

The re-key, and a bug worth naming

Converting the plaintext database to an encrypted one is a one-time operation the owner runs. The script’s design principle is that it never destroys anything until the replacement has proven itself.

A seven-step ladder: check prerequisites, back up the plaintext, checkpoint and drop the WAL, export under the key with sqlcipher_export, VERIFY the encrypted copy by opening it with the key and checking integrity and table count, only then swap it in, and finally the owner destroys the backup. Step five is highlighted as the gate. A side panel lists the failure semantics: original untouched, attempt discarded, no half state, idempotent.
The passphrase reaches sqlcipher on stdin, never as an argument — so it stays out of shell history and out of ps.

Two details in there are the kind of thing that only shows up when you actually run it.

The passphrase goes to sqlcipher over a here-doc on stdin rather than as a command-line argument. As an argument it would land in shell history and be visible in ps to any other process on the machine — a local disclosure that would quietly undo the entire exercise.

The second is a genuine false-positive I had to design around. The installed SQLCipher CLI prints ok on stdout for the PRAGMA key statement itself. The natural verification — apply the key, run PRAGMA integrity_check, check the output contains ok — therefore passes with a wrong key, because the real integrity check fails to stderr and the stray ok from PRAGMA key is the only thing left on stdout. A verification step that returns success on a wrong key is worse than no verification, because it will happily green-light swapping a garbage file over your data.

The fix is unglamorous: emit every value behind a unique marker and extract only marked values.

enc_check=$(sqlcipher "$ENC" <<SQL 2>/dev/null
PRAGMA key = '${KEY_ESC}';
SELECT 'ENCCHK:' || integrity_check FROM pragma_integrity_check;
SQL
)
[[ "$enc_check" == *"ENCCHK:ok"* ]] || abort

A wrong key produces no marker at all, so the check fails correctly. This is the same class of mistake as the RLS bug I wrote about on Watari: a control that exists, is reviewed, reads correctly, and does not work.

The six skills

Skills are reusable SKILL.md packages that agents invoke by name. Six ship, and what strikes me looking at them together is that none of them is a feature. Each is a rule written down after something went wrong.

Six cards, each with what it guards, the skill name, a two-line description, and the failure it was written after. Guards the data: safe-staged-migration. Guards competence: hire-team-member. Guards the premise: owner-intake-interview. Guards the numbers: goal-pace-check. Guards the loop: run-review-cadence. Guards attention: collect-open-tasks.
A skill here is not a feature. It is a rule written down after the system got something wrong once.

safe-staged-migration is the write protocol above — the largest of the six, and the one every other member has to obey before touching data.

hire-team-member is the growth pipeline, with the rule that the roster is checked before the attempt rather than after a bad-fit member has already produced something plausible.

owner-intake-interview is how a specialist establishes a baseline before advising. It exists because a plan built on assumptions about the owner is a wish, and it carries one detail that shows the security model is not bolted on: plaintext identity and preference answers go into the owner profile, but sensitive health and finance answers go straight into the encrypted store and never into a Markdown file.

goal-pace-check is the one that says outright why it exists: “on pace” was once asserted without the math behind it. It now requires four inputs — baseline, current, target, real deadline — refuses to give a verdict without all four, and computes the required weekly rate and the projected endpoint with the arithmetic shown.

run-review-cadence runs the daily-to-quarterly loop, with the instruction to pull current numbers rather than review from memory, and the standard that a review must change behaviour rather than be logged.

collect-open-tasks is the orchestrator’s round-the-table sweep for things genuinely blocked on the owner — deduped, ranked, and explicitly excluding anything already scheduled or answered.

One honest note: CLAUDE.md still claims no skills ship in v1. The charter has drifted from the repository, which is what happens when documentation lives in a second place. It needs fixing, and it is a small illustration of why schema.sql is regenerated from the live database rather than maintained by hand.

The throughline I did not plan

Writing the skills surfaced something I had not noticed while building: they are all the same rule in different clothing. The system is not allowed to assert what it has not proven.

The write protocol will not claim a migration is safe — it demonstrates it by applying it twice to a scratch database. goal-pace-check will not claim “on pace” — it shows the arithmetic against a real deadline. And the habit tracking refuses to report a number it has not earned, which produced my favourite piece of schema design in the project.

Two fourteen-day strips of the same data. Left, over calendar days: eight ticks over fourteen days reads as 57 percent and as six missed days. Right, over observed days: four days are drawn as dashed outlines and excluded, giving 71 percent coverage as a tooling metric and 80 percent adherence as a behaviour metric.
One misleading figure becomes two honest ones, and “unknown” stays unknown instead of rendering as a red zero.

If your source of record for habits is a paper journal, the database is a periodic transcription of it — so a missing row does not mean you missed the habit. It means that day has not been transcribed yet. Scoring adherence over calendar days conflates those two and produces a number that is confidently wrong in the discouraging direction.

The schema splits it. A day is observed if it has a daily journal entry, and adherence is scored over observed days only. Inside an observed day an absent tick genuinely is a miss; outside the observed set, absence means nothing at all. That yields coverage — a tooling metric, is the transcription ritual running — and adherence — a behaviour metric. Zero observed days gives NULL, never 0, and nothing is permitted to render a NULL adherence as a zero or a red.

The same instinct shows up in the status vocabularies. accounts.status is a five-value state machine rather than a boolean, because “is this account open” had grown a third and fourth meaning that were living in a notes string. The reading rule that falls out of it is the sort of thing you only learn by getting it wrong: anything summing money must filter status <> 'closed', not status = 'open', because a blocked or closing account still holds a balance. And supplements.is_current is kept as a trigger-derived compatibility column where a write that disagrees with the underlying state is refused rather than silently accepted — because the dangerous direction, a clinical hold flipped to “taking it” by a stale one-line UPDATE, must never pass quietly.

Where this does not apply

This is a single-owner design, and most of what makes it safe depends on that. One person holds one passphrase and applies every write personally. That does not scale to a team, and the moment two people need access you are into key distribution and audit trails, which is a different and much larger problem.

It is also deliberately slow at the write path. If you want an agent that logs your workout automatically while you are still in the gym, staged files reviewed by hand are the wrong shape and you should not pretend otherwise. The trade I made is that writes to my own health and finance data are rare, consequential, and worth reading before they land. Applied to high-frequency low-consequence data, the same protocol is just friction.

And none of it defends against the model being wrong in ways that are structurally valid. A staged migration can be perfectly guarded, idempotent, validated — and still record the wrong number, because an agent misread a lab report. The protocol guarantees a write is reviewable and reversible. It does not make it true. That is what the review cadences are for, and it is why the owner reads the file before applying it rather than after.

The repository is MIT on GitHub. The five example specialists are scaffolding — the point is the machinery underneath them, and the fastest way to see whether the idea holds is to ask it for something nobody on the roster can do yet.