@miadi/connector
v0.1.37
Published
The Miadi connector: one remote MCP service through which a person signed in with their Miadi person token, from ChatGPT or any MCP client, reads sessions, reviews, episodes and circles and records observations, inquiries, handoffs and proposals with thei
Readme
@miadi/connector
One remote MCP service through which a person reaches Miadi from ChatGPT, Claude or any MCP client. Each person signs in with their own Miadi person token. They read agent sessions, Miadi reviews, Chronicle episodes and circles, and record observations, inquiries, handoffs and proposals. Every record carries the person's name.
The contract comes from jgwill/Miadi#815 (Connector v2: three domains, five accountable circles) and jgwill/Miadi#816 (Review Bridge). The session reads use Miadi's GET /api/session and GET /api/session/{id} (jgwill/Miadi#746).
Connect
| client | how |
|---|---|
| ChatGPT | Add a custom connector (an app with an MCP server) with the URL https://mcp.sanctuaireagentique.com/mcp and OAuth authentication. ChatGPT registers itself; the sign-in page then asks for your Miadi person token once. |
| Claude Code | claude mcp add --transport http miadi https://mcp.sanctuaireagentique.com/mcp --header "Authorization: Bearer <your Miadi person token>" |
| any MCP client | Streamable HTTP at /mcp, OAuth 2.1 with dynamic client registration and PKCE, or a Miadi person token as Bearer. |
A person token comes from /me on Miadi (or an admin, under /admin → People).
What it can do
Call get_capabilities first. It names the signed-in person, their Miadi API tier, their scopes, and the status of every tool.
| kind | tools |
|---|---|
| reads | get_capabilities get_context list_circles get_circle list_sessions search_sessions get_session get_evidence read_episode_file get_evaluations get_narrative_beats review_lookup review_versions review_transcript_status handoff_status list_records get_run_result mission_status provider_capabilities mission_model_preflight search fetch list_research_artifacts read_research_artifact |
| contributions | ensure_review_transcript append_observation open_inquiry link_records propose_narrative_beat record_evaluation register_source_intent record_handoff update_handoff prepare_calendar_event save_research |
| requests (approved by a person) | propose_work propose_session_completion request_authorized_run request_session_steer revoke_session_steering request_review_operation request_deep_diver link_review_to_episode request_inquiry_episode |
| decisions in the conversation | show_decisions (cards), decide_request (called by the card only) |
- Reads need Miadi's read tier and stay within it. A person Miadi gives no API tier reaches only
get_capabilities. Sessions need the write tier, because a session's name and pane are operator data. For the same reason, below the write tier a record someone else made shows no session, working folder or seat, and no long bodies. - Episode files.
get_evidencelists an episode's files; each text file Miadi can preview carries aread_ref(miadi-chronicle://<n>/<path>).read_episode_file(orfetchwith that ref) returns the file's whole UTF-8 text, read as the person through Miadi's file door, so Miadi's audience and room rules apply. The answer names the episode, the path, the content type, the byte count and the sha256, andverifiedsays whether the text received hashes to what Miadi read. Only md, txt, yaml, yml and json files up to 512 KiB are read. A traversal, absolute, hidden or encoded path is refused before any URL is built. A longer text comes in pages cut on character boundaries: passpage.nextback, and the joined pages hash tosha256. Other kinds answerunsupported, a file over the limittoo_large, a missing onenot_found, a ceremony note below the write gateforbidden. - One narrow grant. A person with no Miadi API tier who holds
chronicle_foundations_read(ticked by an admin under /admin → People → Additional permissions; admins hold it through their role) reachesread_episode_filefor foundation documents only:foundations/<topic>/*.md, read through Miadi'sGET /api/chronicle/episodes/<ref>/foundations/<topic>/<file>.md, which checks the permission on every read. Naming the episode alone lists them. Every other file and every other tool stays refused, and the permission gives no API tier. Unticking it refuses the next read.get_capabilitiesreports it underfoundation_read, from Miadi's answer at that moment. - Contributions need Miadi's read tier and the
miadi:contributescope, chosen at sign-in. A record is moved on (update_handoff) only by its author or the write tier, and a request's receipt comes only from its decision. They append a record to the ledger and change nothing else, exceptsave_research: it saves a report on the inquiry shelf as a new folder (the Markdown exactly as given, owner and visibility recorded, private by default), opens the folder's inquiry issue, and commits and pushes the folder in the shelf's repository. A long report is sent in parts (part,parts,upload_id) and saved when the last part arrives; the answer returns its bytes, sha256 and first and last lines. - Requests append a record that waits for a decision. The answer gives an
approval_url; Miadi's/researchpage offers the same decisions to a signed-in person with the write tier, andshow_decisionsshows them as cards inside the conversation (below). The person approves, approves with changes (their instructions are carried into what runs), sends it back for revision with instructions (revision_requested, nothing runs,get_run_resultreturns the instructions), or declines. Only the approval door runs a request, once, as that person; a second decision on the same request, at the same time or later, is refused:- a run becomes a brief in the factory queue, which the intake delivers to a session;
- a work proposal starts its work the same way, as a brief written from the proposal;
- a review operation calls the review service;
- Deep Diver marks the review version ready;
- an episode link goes through Miadi's episode door;
- an inquiry is woven into an episode through its inquiry door, or a new episode is minted with it;
- a session ending is recorded, and no pane is closed.
The model that asked cannot approve its own request.
get_run_result returns one projection of the decision, dispatch/completion state, receipt link and outstanding instructions. The in-chat card, approval page and Miadi's research page use that same read model. Asking show_decisions for an already-decided record_id shows its receipt without issuing another decision token. An in-flight card can refresh status without repeating the operation. A completed basic review with unapplied approval instructions remains open as changes outstanding, not falsely finished.
A review's transcript (jgwill/Miadi#995)
A review is processed from the words of its video, so the connector acquires them itself, for a review made anywhere: the review service's own page, a manual edit, another client, or this connector's create.
ensure_review_transcriptstarts one job per review. It needs a person who may contribute and who holds Miadi's write tier or registered the video's source. It asks no transcript decision and needs no pasted text: processing a review already carries that authority. It never overwrites, never creates a version and never publishes.- Order. 1. The transcript the review service already holds is kept, read back and its digest recorded. 2. A source registered as withheld is held: nothing is read or stored until it is released. 3. The video's captions in its spoken language (the video's own
language; YouTube auto-dubs a video into many languages and lists an-origtrack for each dub, so the first-origtrack is not taken, and a track in the wrong script is refused). 4. A transcription made on this host (MIADI_TRANSCRIPT_LOCAL=faster-whisper, off until the operator enables it, one at a time, at mostMIADI_TRANSCRIPT_LOCAL_MAX_MINUTESminutes), only for a source its owner released. No model service is called and nothing costs money. 5. OnePOST /api/reviews/{id}/transcriptwithoverwrite: falseand the words' provenance. 6. A readback of the stored text, whose sha256 must match what was sent. - Progress and failure. The answer comes within about 20 seconds with the job's state and goes on if it needs longer.
review_transcript_status(read-only;verify: truereads the transcript back now) saysunknown,absent,acquiring,stored,heldorfailed, with the stages, the attempts,next_attempt_atandgive_up_at, the digest and coverage of what is stored, and for a failure its code, the remedy and who can do it (operator,source_owner). Captions that YouTube has not made yet are tried every 15 minutes for three days. A job a restart interrupted resumes from its receipt; a held job resumes when its source is released. - Links that need no card. Every answer carries plain links:
status(/transcripts/<review id>, a page that asks for a Miadi person token, or JSON withAuthorization: Bearerand?format=json),review,transcript_api(the review service, with its token) andjob(the job's record).decisionisnull: no decision stands between the request and the store.review_lookupandreview_versionscarry the sametranscriptstate, andshow_decisionsandget_run_resultanswer with a line and a link for each request, so a client that draws no card still shows where things stand. - Every review, however it was made. The review service announces
review.createdfor each review it creates (its site, its API, a Shortcut, this connector), built with@miadi/hooks-core'sbuildReviewCreatedEventand signed like its other factory webhooks. Miadi's/api/factory/review-eventsnormalizes it, records it in the event ledger (@miadi/hooks-gateway,MIADI_EVENTS_DIR, default/srv/miadi/events) and hands it to this connector's inbox,inbox/connector-transcripts/. Every 15 seconds the connector takes up to three: it asks the review service for the review again, starts the review's transcript job (a review that already holds one stores nothing, a withheld source is held), records the fact as the job's authority, and moves the file todone/or, with the reason, torejected/. A fact waits in the inbox while the connector is down or the review service cannot be asked. - Endings are facts too. When a job reaches
stored,heldorfailed, the connector recordsreview.transcript.stored,review.transcript.heldorreview.transcript.failedin the same ledger, on the review's trace (miadi-review:<id>) and naming the fact that caused it. The same ending recorded twice is one fact. - A worker's door. A factory worker's account holds no Miadi API tier, so it cannot call the tools.
node packages/connector/scripts/ensure-transcript.mjs <review id> --mission run_request:<id> [--verify]runs the same job under an approved run or work proposal that names the review (a person with the write tier approved it, and the job records the mission and who approved it).--statusreads the state without a mission. A second process's attempt is left alone until it has written no stage for five minutes. - The host's reading runtime.
get_capabilitiesloads the moduleMIADI_READING_ADAPTERnames (scripts/host-reading-runtime.mjson gaia) and reportsreading_workflow.provider_runtimefrom what it finds:verified_transcript_onlywhile only caption acquisition has a provider. A reading acquires its transcript with the same reader and the same language rule, keeps durable stage receipts, and stops at the generation stages with a named blocker. Acquiring a transcript does not depend on the review service's import handshake.
Revising a mission before it is decided
A mission is revised, never re-requested (jgwill/Miadi#919). revise_pending_request changes an agent run or work proposal that still awaits a decision, or that its approver sent back, and gives it its next version. Pass the version you read as expected_version and only the fields that change. The request keeps its id, its approval page and its one card. A change of provider is verified on the host again. Each revision is a record in the ledger carrying the whole new content, its reason, author, changed fields and sha256 content hash, so every earlier version stays readable verbatim with get_request_history. A request already approved, queued or declined is refused: a change after a decision is a new request or a steer.
A decision names the version it decides. The approval page carries the version it shows, and each card's decision token is bound to its version. A page or card that showed an earlier version is refused as stale_version, so no brief is queued for a version the person did not read. The receipt and the brief (version="N", and the hash) name the version approved.
Requests made before this, one new request per revision, are joined with supersede_pending_requests: name the request that stands and the exact ids of the earlier ones. Each earlier request must still await a decision and be asked by the same person. It is retired (Superseded by a later version: it can no longer be approved, and its page says which request to decide) and becomes an earlier version of the standing request. All are checked before anything is written, and a request already approved or queued is refused, never cancelled.
Following and steering a mission
mission_status joins one mission read-only: its source request and decision, the sessions bound to it (each with how it is bound — a signed session: ref, the delivered brief's target, or the requested name), its issues and PRs, its steering grant, and where it stands stage by stage — approved, queued, delivered, running, merged, released, closed — each by its evidence, so an idle pane or a merge is never read as completion. Live panes and Miadi's session view are joined only for the write tier.
A mission is found by the issue it implements (jgwill/Miadi#913). request_authorized_run and propose_work take primary_issue; older requests are read by their first issue ref, as an inference. An issue held as primary by several requests answers ambiguous_mission with the candidates, an issue only cited answers related_only, and only a record id or a declared primary that no older request holds by inference (contested) authorizes a steer, release or closure (identity.authorizes_actions). A release receipt names artifact, target, production check and check time; record it as completion_receipt.release on a handoff that cites the mission. released is verified only when the connector confirms it itself (the target is miadi-connector and the running version is the one released); any other complete receipt within seven days is receipt_recorded, with who recorded it and whether that was the approver. Old checks are stale; a version string, a future date or an incomplete receipt is claimed_unverified. closed needs an approved propose_session_completion ending (exit or complete_then_exit) that names the mission in its mission field and ends one of the mission's sessions; a completed request is request_completed. A GitHub reference the connector cannot read is unverified, with the reason (no_github_token, not_visible_to_token…), never not_found.
The factory binding (jgwill/Miadi#909). The factory runner records every brief it hands to a seat in delivered.jsonl: the record, the tmux session, the pane id and the Claude Code session id that took the line. mission_status reads that receipt (write tier) and checks it against Miadi's session registry: binding is verified when both agree on session, tmux session and pane, receipt_only when the registry cannot confirm it, unverified_association when the registry places the session elsewhere or the pane is gone. Busy or idle comes from the registry when the binding is verified, else from a read-only peek of the bound pane, and is never read as completion. mission_status with session:<uuid> finds the one mission the factory delivered to that session (factory_bound); two are ambiguous_mission. provider names the provider requested, resolved and actually run, and match: false when they differ; nothing is re-run because of it. get_session lists the missions the factory delivered to the session (binding: "factory_receipt"), then the miadi-run missions Miadi links to it from their records and its inventory record (binding: "miadi_run", jgwill/Miadi#993).
The agent's result (jgwill/Miadi#921). An agent returns its work with propose_session_completion naming the mission in mission, from its bound session, with its evidence refs and, optionally, a structured result: summary, outputs (ref, label, git_sha), tests (command, passed, failed, skipped) and blockers. mission_status and get_run_result read it back by the mission id as result: the standing submission (the newest verified one: it names the mission, ends a bound session and was submitted with Miadi's write tier or the factory's writer token, which the connector records on the ending as submitted_with_tier; anything else naming the mission is unverified_association with an association_code, a mere citation cited_only and never a result), its summary, tests and blockers as the agent's claim, and each output typed and checked when read. A commit or file on GitHub is read through GitHub (GITHUB_TOKEN when the host has one): verified with its sha, changed_since_submission when its declared git_sha no longer matches or the file changed on its branch after the submission, unverified with the reason when GitHub cannot be read, never not_found. A connector record id the ledger does not hold is not_found. Bound sessions are taken from the factory's receipts for every reader, so the verdict is the same at every tier; below the write tier the session ids are left out. The completion decision stays the person's, and only a verified submission sets it: stage_truth.completion is pending_approval, accepted, declined or revision_requested (none for an unverified one, whose stage is result_submitted_unverified; an approved exit submitted below the write tier closes nothing), pending_human_gates lists what waits on a person with its approval page, and mission_stage names the furthest stage with evidence (result_submitted_awaiting_completion_approval, completion_accepted, merged, released, closed…). A duplicate submission (same session, ending and mission, still pending) is handed back with replayed: true, and not_recorded names what the second call carried that was not kept.
request_session_steer sends one person's correction to a running agent, within the steering grant an approved run carries (steering: { max_attempts, expires_at, pane } on request_authorized_run). Busy is the normal case (jgwill/Miadi#930). STEERING.md is the contract every provider's adapter keeps (miadi.steering/1, jgwill/Miadi#942), with what was shown on real sessions of Claude Code, Pi, Copilot and Codex and each one's limits. get_capabilities.steering and provider_capabilities serve the same matrix, with what this host would load into a new session.
| who | owns | never does | |---|---|---| | The person, through ChatGPT or any client | one correction, in scope | watch tools, wait for idle, send it again | | The connector | who may send it, the grant, mission → session → runtime, idempotency, handing it to that session's inbox, reporting what happened | decide when the model reads it, type into a working agent's pane, call a forward a delivery | | The runtime (each provider's adapter, in jgwill/miadi-orchestration-kit) | the inbox, its order, the checks again at consumption, when its model reads it | deliver anything to a subagent or to another session | | The model | reading it and changing what it does next | |
The route comes from what each part signed: the factory's receipt names the session, Claude Code's own registry says it runs and in which pane, and the adapter's adapter.json says this session loaded its hooks. A wrong session is stale_binding, a wrong pane wrong_pane. A provider with no adapter, or a Claude session started before the adapter (hooks load at session start), answers unsupported with the reason and uses no attempt. No Claude Code behaviour is assumed of Codex, Copilot or Pi, and get_capabilities.steering.contract names the adapters that exist.
The inbox is $MIADI_STEERING_DIR (default <factory>/steering), <session_id>/pending/ and its siblings, one Unix account's, 0700. The connector writes one envelope with fsync and rename. The adapter takes it at the next PostToolUse or PostToolUseFailure as additionalContext in the same turn, or at Stop with decision: block when no further tool follows. A correction landing while the turn ends, after Stop has looked, is caught by a background watcher that runs for 30 seconds after each turn (asyncRewake): exit 2 wakes the model with it, so a working agent never waits for idle or a timer. A seat already idle is typed at once (below). The factory runner's next pass is the last net for anything left (wakeIdleSteers, once per envelope, only if the prompt box is clear), for example a session that resumed with a correction waiting. delivery.continuation says which of these is coming: next_tool_or_turn_end, runner_wakes_idle_seat or session_not_running. One rename into claimed/ decides who delivers, so it is delivered once and never replayed. The adapter checks the session, the hash, the expiry (the grant's, or one day) and revocation again when it consumes. The hook does not poll: it runs in the agent's own lifecycle, and nothing outside watches it. A hook on an interrupted tool does not consume, because Claude Code drops hook messages there, and a subagent's tool does not consume either.
Why hooks: Claude Code 2.1.318 has a native mid-turn queue, and claude --help offers no outside door to it. Typing reaches it through the keyboard, which is the terminal heuristic this replaces. The cross-session socket (messagingSocketPath) belongs to Claude sessions speaking to each other: its protocol is not published, and a connector speaking it would pose as a session. PostToolUse.additionalContext and Stop are its documented interface, so that is the adapter. Pi, Codex and Copilot have their own in-turn mechanisms, and none has an adapter here yet.
delivery in the answer, in get_run_result on the steer's id (steers on its run), and in mission_status.steering.steers (write tier) says where it stands, each state with its source and time. The steer's approval page shows the same states and times, without tool inputs:
| state | means | source |
|---|---|---|
| authorized | the grant or a person's decision covered it | connector |
| forwarded_to_runtime, queued_at_runtime | it is in the session's inbox; the model has not necessarily read it | connector, runtime inbox |
| delivered_to_agent_context | the agent recorded it among what it gave its model | its own record (Claude Code's transcript attachment, Pi's session file, Copilot's events.jsonl, Codex's rollout) |
| acknowledged | the agent's own next text names steer <id> received | its own record |
| behavior_adjusted | a later tool input holds what the steer named in expect_next_action; without that, behavior: not_judged and the next actions are shown for a person to judge | its own record |
| unsupported, expired, blocked | it never reached the model, with the reason | connector or runtime hook |
| unknown | claimed with no transcript record: ambiguous, and it is not sent again | |
The record itself moves with its evidence: completed once the model has it, blocked once it never will, on the next read by anyone. So the ledger, mission_status and the decision surfaces say the same thing.
revoke_session_steering stops a run's steering or one steer. What is still pending moves out at once, a marker stops anything later, and the answer says exactly what was revoked and what the agent had already consumed. The same idempotency key gets the first answer back, and the same words under a new key answer duplicate. Only a typed or forwarded steer uses one of the grant's attempts.
An idle seat takes the same inbox, and the connector types into no session that has an adapter (jgwill/Miadi#942). Pi's and Copilot's adapters start a turn themselves. A Codex seat gets codex queue from the factory runner. A Claude Code seat gets one line from the factory runner, which holds none of the correction's words and waits for anyone's words at the prompt; the connector asks for that at once with miadi-factory-runner wake (MIADI_FACTORY_RUNNER_BIN), and the runner's timer is the net. The one case still typed by the connector is a Claude Code session from before its adapter, idle with an empty prompt: into the mission's bound pane, never whichever pane is active, with an exact tmux address (=name:w.p). It peeks first, presses Enter again while the words still sit at the prompt, and re-peeks after. Text already at the prompt, or a prompt box it cannot read, stops it. Any other provider without an adapter answers unsupported, and nothing is typed.
Rollback: run the connector version before this one (git checkout of its dist, then systemctl --user restart miadi-connector). A steer to a Pi, Copilot or Codex session then answers unsupported again and nothing else changes. To stop an adapter, follow STEERING.md; a new session then has no adapter.json and answers unsupported. Envelopes already queued stay on disk, unread.
request_authorized_run may name a provider (codex, pi, copilot, claude). The connector verifies it is installed on the host before recording anything; an unknown or unavailable provider answers provider_unavailable and the mission stays unstarted. With no provider named, a person's own default applies, then one the operator set for them, then Claude Code, and the result is named in the brief. A default that is missing or shows no sign-in leaves the mission unstarted, and no other provider stands in for it. Pi, Copilot and Codex otherwise run only when named. provider_capabilities (write tier) reports what the host has installed, the sign-in evidence, the preference order, and the read-only Jev check: unconfigured without JEV_AI_API_KEY or TYPESAFE_API_KEY in the connector's environment, else the models Jev lists for the key (no charge). A provider name inside a task's text selects nothing.
Codex runs only on its requester's own choice (jgwill/Miadi#926). The provider field is the caller's: the connector cannot read the chat, so provider: "codex" chooses nothing for the person. A run that names Codex is recorded, and its answer says it needs_person_choice. The approval page then shows no Approve button. Only the requester, signed in there with their own Miadi person token, can tick and send "Choose codex for this mission". That provider_choice record is bound to the request id, its version and its content hash, must be under 24 hours old when the request is approved, and covers that one mission. A revision makes it stale. A generic Approve, on the page or on a card, is refused as provider_choice_required, and so is a choice by any other person or by a service token. The requester can instead set their own policy at /providers so their requests may name Codex without a per-mission choice; that policy is theirs only and revisable. The check runs at request, revision, approval, and where the brief is written (approval and every rerun). The brief carries provider_authorization and provider_choice (or provider_policy), and the factory runner refuses to launch Codex for a connector brief without them. request_authorized_run, revise_pending_request, mission_status, show_decisions and mission_model_preflight show provider_authorization; after approval it is also in the request's completion_receipt, which get_run_result returns. A Codex launch without it is named on the mission and fails the preflight closed as provider_not_authorized. A brief the runner refuses goes to queue/refused/ with its reason, and the request shows it. Other providers keep their regular policy, and nothing falls back to Codex. What this does not cover: anyone who can write /srv/miadi/factory/queue or the connector's state directory can forge a brief or a record (the folder's permissions are the boundary); a configured launcher that runs Codex behind a wrapper whose name does not say codex is trusted as configured; a brief to an existing session reaches only an idle Claude Code agent today, so it is not checked; and an agent that starts codex itself from inside its session is outside the connector's reach.
Which Claude Code model a run launches on. request_authorized_run and propose_work may name a model, and that model is used. It must be on the host's permitted list, and a model named with any other provider is refused, so a model never moves a mission to a provider nobody chose. With none named, Jev chooses among the permitted models once, when the run is approved. It is sent the task type and criticality (task_type, criticality; default implementation, normal) and the candidates, never task text, a mission id or a repository name, and its choice authorizes nothing. If Jev has no key, is down, abstains, answers below the 0.8 floor or breaks the answer contract, the launch uses the configured Claude Code model, and the receipt's fallback_reasons says why. Jev knows these models only through the fit notes in claude-models.ts, which are the Factory's reading of Anthropic's descriptions and not a measurement: implementation fits Sonnet, file_relevance and normal review fit Opus, architecture_refactor and anything security_critical fit Fable. Its confidence is uncalibrated. A request that fills no task_type counts as implementation, so it goes to Sonnet even where Claude Code's own setting says Opus.
A session already running keeps its model: Jev is not asked, and a named model fails the run rather than being ignored. A request recorded before this behaviour launches as it was approved. If Claude Code is missing or shows no sign-in when a run is approved, the run fails with no brief and can be run again. The brief carries model and model_source, and the factory runner passes --model; publish the runner first, because a runner older than 0.1.5 ignores it. mission_status shows the choice, its source, the flag, the model the session's own transcript says answered, read when asked, and whether they match. A mismatch or an old runner is a note, and nothing is re-run. A config directory is sign-in evidence only: only the transcript shows a launch worked.
factory.json narrows the list and sets the fallback: "claudeModels": ["opus", "sonnet"], "claudeDefaultModel": "opus". Without them every model in claude-models.ts is permitted, and the fallback is the model in Claude Code's own settings. A list that leaves that setting out wins over it, and the fallback is then the first listed. A name goes on that list after a real answer shows it: claude -p "Reply with OK." --model <name> --output-format json names the model that answered under modelUsage, and an unknown name exits 1.
mission_model_preflight (write tier) answers, for one mission, which provider and model fit its task type and criticality, and how that is known: contract miadi.mission_model_preflight v1, candidates with their configured default model, sign-in evidence (config directory only), quota unknown, policy eligibility, a review floor, and a recommendation or an abstention with reasons. Its decider.source is static_fallback, jev or synthetic_fixture. Jev is asked only with consult_jev: true and a key on the host; it is sent task type, criticality and candidates, never task text, mission id or repository, and its answer is advisory: uncalibrated, below 0.8 or outside the contract, the preflight abstains. A reported launch on another provider than the approved one answers provider_mismatch before anything is asked. It authorizes nothing. bench/model-preflight-bench.mjs compares the 0.1.12 baseline, the static preflight and Jev on synthetic scenarios; bench/results/ keeps the runs.
The mission standard
A new or revised run or work proposal answers with draft_review, the report on that version against the mission standard (jgwill/Miadi#939). It lists aspects to revise, each with aspect, name, checked_by, why, ask, settled_by and sometimes span, in needs_revision, and what it did not judge in not_checked. running_missions lists what a person approved in the last 48 hours that has not finished, preferences holds the requester's standing preferences, and next_action says what to do. It carries no overall verdict, it advises and blocks nothing, and a failure in it never refuses a request. The report is also in get_run_result, and the open aspects are on the decision card and the approval page. The worker reads delivery, person_words and any aspect still open at approval in its brief.
| aspect | requires | checked by | settled by |
|---|---|---|---|
| S1 | one exact place the result lands, in delivery | code for an address, an issue, a uuid or a path; Jev below 0.3 otherwise | drafter |
| S2 | who may release, deploy or start a session | not checked yet: the local reviewer (jgwill/Miadi#939 A3) | person |
| S3 | running work on the same ground | code, on a shared issue only; the local reviewer judges the rest | drafter |
| S4 | a mechanism the mission dictates | Jev at 0.8 or above | drafter |
| S5 | the person's words for what is credited to them, in person_words | Jev at 0.8 or above notices the credit; only the drafter can answer | drafter |
| S6 | an episode or an issue that holds the work, and primary_issue when several are named | code | drafter |
delivery is optional in the schema, and a missing one is a finding. A client holding a cached tool list could not send a required field, and would lose the mission instead of revising it.
The drafter gets two rounds: a report and a revision, twice. The third report's next_action sends what is open to the card. One report is kept per record and version in <state dir>/draft-reviews.jsonl, so a person's decision on a version can be read against the aspects open on it. That pair is the ruling the bars will be set from.
The standard is a document. A text prompt named mission-standard, label production, in the prompt store's Engineering domain, read at most once per ten minutes, holding { jev_model, aspects, preferences }. Revising it needs no release, and a report names the version that judged it. An aspect with a Jev check and a new code runs as written, so an aspect can be added there. S1, S3 and S6 have code behind them. A failed read keeps the last good version and says so, and with none the built-in copy answers and says why. A person's preferences live only in the store, because this package is published.
What leaves the host. The mission's text and its delivery, with ids, paths, hashes, URLs and repository names removed, to Jev, when the connector's environment holds a key. mission_model_preflight still sends no task text. Keys go in an environment file only the connector's user can read (EnvironmentFile=-%h/.config/miadi/connector.env): JEV_AI_API_KEY or TYPESAFE_API_KEY, and ENG_LANGFUSE_PUBLIC_KEY, ENG_LANGFUSE_SECRET_KEY, ENG_LANGFUSE_BASE_URL. Without them the report says what went unchecked, and code's findings stand. The bars are uncalibrated. Episode 564 (miadi-chronicle://564, mission-draft-review-261105/) holds the tests they were read from.
Deciding inside the conversation
In a client that renders MCP Apps (ChatGPT among them), show_decisions draws one card per request awaiting a decision: what is asked, by whom, and what approving does. The person decides on the card with their own click: Approve, Approve with my changes, Send back for revision, or Decline. The card then shows the receipt.
An empty card in ChatGPT. ChatGPT keeps the tool metadata it read when the connection was made or refreshed, and draws an empty box for a template it cannot read. Keep these true:
- A card change that breaks the template gets a new URI, and every earlier URI stays readable (
DECISIONS_CARD_EARLIER_URIS, pinned by a test). - After a change to a card or a tool, reconnect the connector in ChatGPT or select Refresh at chatgpt.com/plugins, then start a new conversation. Other clients do the same in their own settings.
journalctl --user -u miadi-connector | grep -E 'card_template_read|"show_decisions"'says what a host asked for. Ashow_decisionsline with no template read means the host drew from what it holds.- While a card is empty, the request's
approval_urldecides the same request.
Earned 2026-11-06: 0.1.22 moved the card to decisions-v2.html and stopped serving v1, and the card stayed empty in the iOS app. After the person reconnected the connector, ChatGPT read v2 and a card approval went through.
The approval page has two steps. The first signs in and approves nothing. The second reads "Not approved yet: decide here" and holds the buttons. page_post in the journal records what each signed-in post carried, with decision: null for a sign-in.
A decision is recorded once. An approved run that calls another service (a review operation, Deep Diver, an episode door, a steer) is written to the ledger as approved and running before the call, so a restart can never offer the request for a second approval; a run the connector was recording when it stopped reads as failed and may be run again. A decision whose run takes longer than MIADI_CONNECTOR_DECISION_WAIT_MS is answered as recorded and running, and the card reads get_run_result every 3 seconds until the outcome is in. The same decision sent again (a host retrying a call, a second click, the page form sent twice) answers with the first one's receipt, marked replayed, and runs nothing. A different decision is refused as decision_conflict, with where the request stands. When the host gives up on a call, the card reads where the request stands before it offers the buttons again.
A person mentioned and a person on the record (jgwill/Miadi#962). A person mentioned, by name or as a kind of user, holds nothing back: a spoken name is never a reason to withhold a source or to ask anyone. A person on the record, meaning filmed or with their own voice, words or screens shown, has a release agreed at the start of the production. Nothing but the owner's withheld registration holds a source back, and no code counts names. request_participant_consent is the door for a person on the record who has no release yet.
An approved create or new version sends the person's words (jgwill/Miadi#963). POST /api/reviews and POST /api/reviews/:id/versions carry source: { title, description, boundaries } from the source_intent: its own fields, or the TITLE:, DESCRIPTION and Review boundaries: lines of its intention. The service keeps none of it, so each generation sends it again, and a new version finds its source through the review's video. The receipt's source_words.title_taken reads from the answer whether the service used them. A video already reviewed answers with the existing review, so a create on it uses nothing.
A created review gets its transcript (jgwill/Miadi#925). An approved create calls the review service, then reads the video's original-language YouTube captions with a local uvx yt-dlp and stores them on the review as [mm:ss] text, so an agent reads them through fetch and the factory's kind briefs find them with miadi_review.py transcript. No model is called for the transcript. The service answers an empty POST /api/reviews/:id/transcript by asking Gemini, so an empty one is never sent. completion_receipt.transcript says where it stands, and the card and the approval page say the same:
| outcome | meaning |
|---|---|
| stored | stored, with its source, word count, sha256 and the range the captions cover |
| already_present | the review already held a transcript, which stays |
| waiting_for_captions | YouTube has no captions yet or refused the read. The connector tries again every 15 minutes for three days, from the ledger, so a restart loses nothing |
| held_source_withheld | the video's source_intent is withheld, so the captions are neither read nor stored. The transcript lands within 15 minutes of the source being released with register_source_intent, because a stored transcript is public at /review/<id>/json |
| no_captions | three days passed with no captions |
| no_review | the create left no review. Running the request again retries it |
A transcript supplied with the request is stored instead of the captions. The captions hold what YouTube heard: they may stop before the video does, and they may hold a synthetic voice and not the person's. Their first line says what they are and the range they cover. The host needs uvx on the service's PATH.
The model that asked cannot decide for the person:
decide_requestis declared for the card only (_meta.ui.visibility: ["app"]), so the host does not offer it to the model.- It requires a decision token that travels only in the
_metaof theshow_decisionsresult. Hosts deliver that metadata to the card and keep it from the model. The token is bound to one request at the version its card shows, one person and one OAuth client, and expires after 7 days (MIADI_CONNECTOR_DECISION_TOKEN_TTL). A decided request no longer awaits a decision, so a token works once. The request itself does not expire: a card whose token expired names the request's approval page, andshow_decisionsgives a fresh card for the same request. - The sign-in must carry
miadi:approve. A person grants it by ticking "Let me approve requests on cards inside this client" on the sign-in page. Only a person with Miadi's write tier receives it, only for a client that sends people back to chatgpt.com, and it ends when Miadi no longer gives them the write tier. A person token used directly as Bearer never carries it.
The approval page itself (/approve/<record>) names only the kind of request until someone signs in. A write-tier person then reads and decides it, and whoever asked can read it.
Without that sign-in, or for a person without the write tier, the cards link to each request's approval page instead.
Every record has a claim category: observed, hypothesis, desired_future, fiction or verified_implementation. Witnessed fact, envisioned features and fiction stay apart.
Run it
pnpm --filter @miadi/connector build
MIADI_CONNECTOR_PUBLIC_URL=https://mcp.sanctuaireagentique.com node packages/connector/dist/bin.js| variable | default | meaning |
|---|---|---|
| MIADI_CONNECTOR_PUBLIC_URL | http://127.0.0.1:<port> | the address clients use; the OAuth issuer |
| MIADI_CONNECTOR_PORT / MIADI_CONNECTOR_HOST | 3337 / 127.0.0.1 | where it listens |
| MIADI_CONNECTOR_MIADI_URL | http://127.0.0.1:3335 | the Miadi app it calls, as the person |
| MIADI_CONNECTOR_STATE_DIR | /srv/miadi/connector | oauth.json (token digests, clients) and ledger.jsonl (records), mode 0700 |
| MIADI_REVIEW_BASE_URL | https://miadi-review-service.vercel.app | the review service |
| MIADI_REVIEW_TOKEN | read from ~/.env | review search and approved review operations |
| MIADI_FACTORY_DIR | ~/.miadi/factory | the queue approved runs are written to |
| MIADI_INQUIRY_DIR | /a/src/IAIP/prototypes/artefacts | the inquiry shelf save_research writes to, inside a git checkout it pushes, and list_research_artifacts and read_research_artifact read back |
| MIADI_INQUIRY_GITHUB_REPO | miadisabelle/Etuaptmumk-RSM | where a saved report's inquiry issue is opened, with the host's gh login |
| MIADI_CONNECTOR_DECISION_TOKEN_TTL | 604800 (7 days) | seconds a card's decision token stays usable, held between 300 and 2592000 |
| MIADI_CONNECTOR_DECISION_WAIT_MS | 20000 | how long a decision answer waits for its run before answering it as recorded and running, at most 50000 |
On gaia it runs as the user service miadi-connector.service, and the Cloudflare tunnel gaia routes mcp.sanctuaireagentique.com to it.
Security
- No Miadi token reaches the MCP client. After sign-in, the client holds only connector tokens. The connector stores sha256 digests of those tokens. It keeps the person's Miadi token in a 0700 directory so it can call Miadi as that person.
- Access tokens last 8 hours. Refresh tokens work once each, and a sign-in ends 90 days after the person signed in.
- Miadi decides who a token belongs to (
GET /api/identity/me), and is asked again at most every 5 minutes while a sign-in is used. A token Miadi no longer recognises ends the sign-in, and the tier Miadi returns now is the one applied. When Miadi cannot be reached, sign-in says so (503) and an existing sign-in keeps the person as last known. - What a requester wrote is placed in an approved run's brief with every tag-opening
<neutralised, so the brief holds one block with the connector's own attributes. - Only the loopback hop (cloudflared) is trusted for the client address. The OAuth endpoints, the forms that take a person token, and failed
/mcpauthentications are rate limited per address. - A card's decision token lasts 7 days. Its lifetime is not what protects a request: using it also needs the same person's bearer, for the same client, carrying
miadi:approve, and the write tier Miadi gives that person now. That bearer can already askshow_decisionsfor a fresh token, so a longer lifetime gives a stolen token nothing a stolen bearer does not. - Each step of a decision is one JSON line on stdout (
"evt":"connector.decision", read withjournalctl --user -u miadi-connector): the request, a correlation id, the door, timings, the outcome and the HTTP status the run met. No token and no instructions are logged. - Outbound calls time out: Miadi 30 s (episode doors 120 s), the review service 15 s (approved review operations 290 s), GitHub 15 s.
Test
pnpm --filter @miadi/connector test