Majlis: Giving Agents Your Life Data Without Giving Them the Key
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 design answer is that there is no key to hand over.
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 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.
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.
Every staged file has to satisfy seven rules, and the interesting ones are not the obvious ones:
- Check
schema.sqlfirst, 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 EXISTSguards on link rows. - No DDL and no
DELETEin 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.
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.
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.
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.