From 021363e78ca96aadb4fa655c3f16df370189eb84 Mon Sep 17 00:00:00 2001 From: ladislav-urban Date: Tue, 20 Oct 2026 11:13:55 +0200 Subject: [PATCH] feat(orchestrator): let an extension check and sign calls to its own MCP server An extension that brings an MCP server (tools __*) had no way to gate calls to it: who may ask, or what must go out with each call (a per-request token naming who is asking). Its only option was patching mcp_gateway.call_tool. extensions.register_gateway_check(GatewayCheck(server, check)) adds one check per server. call_tool asks it after its own refusals (Act as me's lockdown and outside content, AI-app turns, saved files) and before any per-tool gate, with the normalized tool name and a deep copy of the arguments. A GatewayCall with refusal is returned to the model and audited as integration_outbound_blocked (field server_check). With outgoing, the arguments sent are built after every gate, from the model's own (still what is recorded), and again for each send, so the discovery retry never replays a one-request token. It fails closed: - non-object arguments, and a check or outgoing that raises or returns the wrong type, refuse the call; - the proxy routes by the exact tool name, so a name that only normalizes to a checked server ("Kiosk__x", " kiosk__x") is refused rather than signed and sent to whatever server answers to that spelling, and load_mcp_server refuses a runtime server whose name normalizes to one; - if the extension fails to load, its servers stay held (every call refused), never unchecked: those it registered a check for, and those it names in a module-level GATEWAY_SERVERS, which also covers a register() that fails before reaching its check; - strings only outgoing() added are replaced with [redacted] in the answer, should the server quote the arguments back. Server names are single-_-separated letters and digits ("shop_" would never match, as "shop___x" splits as "shop"); the built-in servers (google_workspace, microsoft_365, fetch) take no check, since core gates them. Co-Authored-By: Claude Opus 5.5 --- .../architecture/architecture-facts.yaml | 23 ++ .../architecture/prebuilt/agents.json | 4 +- .../architecture/prebuilt/integrations.json | 4 +- .../openexecutive/orchestrator/extensions.py | 105 ++++++++- .../openexecutive/orchestrator/mcp_gateway.py | 142 ++++++++++- packages/core/tests/unit/test_extensions.py | 32 ++- .../unit/test_mcp_gateway_extension_checks.py | 220 ++++++++++++++++++ 7 files changed, 519 insertions(+), 11 deletions(-) create mode 100644 packages/core/tests/unit/test_mcp_gateway_extension_checks.py diff --git a/packages/core/openexecutive/architecture/architecture-facts.yaml b/packages/core/openexecutive/architecture/architecture-facts.yaml index ad59bfb04..a4585070a 100644 --- a/packages/core/openexecutive/architecture/architecture-facts.yaml +++ b/packages/core/openexecutive/architecture/architecture-facts.yaml @@ -342,6 +342,29 @@ caching: that tool only, and the handler decides what an unattended call may do (it must not call other extension handlers or leave tasks running). read_only=True is the only way to be labelled reads-only on the review card. + register_gateway_check(GatewayCheck(server, check)) gates one MCP server + the extension brings (tools __*; the name has no "__", never a + built-in server - google_workspace, microsoft_365, fetch - and one check + per server; rolled back with the rest of a failed register()). + mcp_gateway.call_tool asks it (_extension_server_call) after its own + refusals (outside_reach_refusal, mcp client turns, saved files) and + before every per-tool gate, with the normalized name and a deep copy of + the arguments (non-dict arguments are refused first). A GatewayCall with + refusal is returned as the error and audited integration_outbound_blocked + (field server_check); with outgoing, _wire_input builds what is sent + from the model's arguments after every gate and again for the discovery + retry, so a per-request token is never replayed. A check or outgoing + that raises or returns the wrong type refuses (fails closed). The raw + name's server part must equal the check's server exactly (the proxy + routes by exact name): a spelling that only normalizes to it is refused + (name-spelling), and load_mcp_server refuses a runtime name that + normalizes to a checked server. Strings (8+ chars) only outgoing added + are replaced with [redacted] in result_text (_string_leaves). Server + names are single-_-separated letters and digits (_SERVER_NAME: no "__", + no leading or trailing _). A failed register() leaves its servers held, + not unchecked: each registered check and each name in the module's + GATEWAY_SERVERS becomes _held_check, which refuses every call; a module + that fails to import names none. deferred_tool_groups: | The less common skill tools are not in the tool list. orchestrator. tool_groups.GROUPS puts them in named groups (calendar, people, memory, diff --git a/packages/core/openexecutive/architecture/prebuilt/agents.json b/packages/core/openexecutive/architecture/prebuilt/agents.json index 88aff2549..00d248052 100644 --- a/packages/core/openexecutive/architecture/prebuilt/agents.json +++ b/packages/core/openexecutive/architecture/prebuilt/agents.json @@ -1,7 +1,7 @@ { "section_id": "agents", "title": "Agent Council", - "markdown": "The Executive (`openexecutive.orchestrator.executive.Executive`) is a single coherent persona that fans out to specialist sub-agents via Anthropic tool use. Each specialist subclasses `openexecutive.agents.base.BaseAgent`, carrying a `name`, `domain`, and `model`, and exposes `analyze(...)`, which wraps the question in user-turn context and calls Claude through the per-call provider. That context is per call and never cached: `` (the stage from the company profile — specialists never see the profile itself), in solo mode `` (what kind of principal the advice is for, in plain words, with their title and remit — `router.principal_role_context`), ``, ``, `` (the shipped case studies, and under them the workspace's own lessons that match the call, `memory/lessons.py`), `` and ``, outermost first. The specialist's own system prompt is a static domain constant under one `cache_control` block, so it stays byte-identical across companies and profile edits. A chat turn takes the stage from its session's profile (the one the Executive reasons over, and the one an eval scenario injects); workflow steps and the MCP server's `consult_specialist` read it fresh from the profile on disk. The role tag follows the turn's mode: a chat turn pins the role on its session with the mode (`pin_turn_principal_role`: the session's `principal_role` override, else the workspace settings) and sends it in solo and nothing in team, so the org block, every specialist in the turn and any workflow the turn starts see the same role; callers with no chat turn read it fresh (`load_principal_role`: the current session's mode and role, else the workspace's), and a failed read sends no tag rather than failing the consult. The CFO, CSO and sales prompts key their advice on it: for a bootstrapped, self-funded or solo company they drop venture fundraising benchmarks and work from cash, and raise only if the founder wants to.\n\nRouting uses one `consult_specialist` tool. When to call it is spelled out in the persona's Domain Expertise section (\"Going deeper\") and in the tool's own description: any question that asks for judgment in a specialist's area (a decision, plan, analysis, negotiation, hire, contract term, pricing or fundraising move, board message, or a department's own targets and stance, since the specialist holds that department's memory) is consulted before the answer, several at once across areas; lookups in mail, calendar or documents, scheduling, status checks, routine drafting and questions about how the Executive works are answered without one. Chat eval scenarios hold this: `required_routing` names specialists the turn must consult and `no_routing: true` says it must consult none, and the runner fails a scenario that misses either (`evals.scenarios.routing_check`). Beside it the Executive carries two skill tools, `search_skills` and `load_skill` (skills are shown to users as **playbooks**, and every playbook belongs to a workflow: one that follows it, or its own Quick workflow). Chat finds and reads playbooks but never creates or changes one: work worth repeating is offered as a workflow through `draft_workflow`, with the method written into its steps, because workflows read playbooks at run time (approved scheduled ones included) and this loop also runs on inbound email and chat, so only a person can change what a workflow follows. A hidden built-in is invisible to search and load; each `search_skills` hit lists the `workflows` that follow it, Quick ones included, and the persona routes \"the finished deliverable\" to that workflow and quick or partial asks to the playbook inline; Tool calls made while proactive work is tagged (scheduler dispatch, reflection, research, alert review) feed the Attunement outcome ledger. Goals have three tools: `list_department_goals`, `update_department_goal` (progress the user reports) and `create_goal` (a new goal the principal states, filed under an area by slug or title and creating the area when none matches — see Org). `create_goal` runs only for the principal on a verified surface, and no unattended run is offered it: the tools in `schedule_tools.UNATTENDED_WITHHELD_TOOLS` are dropped from reflection's and research's toolkits by `unattended_toolkit`, and from the chat loop when its Session is marked `unattended` — the scheduler's PROACTIVE TRIGGER run, whose prompt quotes stored intent text; a call the model emits anyway gets an error tool_result. `record_decision_outcome` records how a past decision turned out — the weekly review lists decisions older than 30 days with no outcome as `[decision N]` — writing `decisions.outcome` (`episodic.update_decision`) and auditing the rationale; it has `create_goal`'s gate (the principal on a verified surface only; everyone else gets an audited refusal) and is in `UNATTENDED_WITHHELD_TOOLS` too. A missing or unknown id returns the decisions still waiting on an outcome, so the model can retry with the right one. Its schema is static; the solo persona has one line on it, and team mode learns it from the tool description, so the team persona is byte-identical. Attunement adds `list_open_loops` (what a person owes — everyone's for the principal only in a private conversation, otherwise the caller's own) and `close_open_loop`, which only closes a loop when the turn's resolved speaker is the principal or the loop's owner — an unrostered sender, a signed-in web user not on the roster, and unattended sessions are refused. `assign_open_loop` opens one on request (\"ask Ben to send me the Q3 numbers by Friday\") for the principal or a rostered teammate on a verified surface — never an email, a turn private to the principal or an unattended run, which are also withheld the tool — and only when `task` appears verbatim in what the speaker typed this turn, at most 5 per turn; that turn's extraction pass then opens no second loop for the same owner. The owner is told once on their own channel when someone else assigns it, and the result's `notified` says how that went, so the Executive reports it rather than messaging them as well. The roster write tools (`upsert_person`, `archive_person`, `set_department_head`) are the principal's alone, and only on a surface that verified the speaker — the web chat, the principal's own Slack or Discord, a channel whose adapter verified the sender itself (`Session.speaker_verified`, with the `caller_person_id` it resolved, never on a turn an email started), or a private Telegram chat when `TELEGRAM_WEBHOOK_SECRET` is set to a value Telegram can send (Telegram identifies the chat rather than the sender, and without that secret its webhook accepts anyone's update). A teammate, an email (even from the principal's address), Google Chat, the CLI, the MCP server and unattended runs get `status: refused` (audited, and no action chip), because a team member's People row is also the web sign-in and outbound-email allow-list. `upsert_person` takes `kind` (`team` / `contact`; a new person defaults to `contact` in solo mode, an update keeps the current kind) and `email_aliases` (other addresses the person writes from — the full list, replacing the current one; they match mail and may be emailed but never sign in, and one already on someone else is refused), and after every write any pending roster request whose sender now matches someone is closed and its held messages replayed. `resolve_roster_request` answers a **roster request** — someone not on the People list who wrote in and is waiting (see Org): `approve` (a new person; `kind` defaults to team for a company-domain sender, else contact, when the principal did not say), `link` (someone already listed, by `person_id`; `replace_channel_id` only when the principal said so) or `decline`. It has the roster tools' gate (the principal on a verified surface; audited refusal otherwise), is in `UNATTENDED_WITHHELD_TOOLS`, and answers only an id in `Session.trusted_roster_request_ids` — the ids the server printed that turn in a `` block (`briefing.context.format_roster_requests_for_prompt`, up to 10 pending requests), which `render_and_trust` appends to the `` digest on the principal's own verified turn only and records on the session in the same step, so an id the model invents or reads out of a message adds nobody. Each line is server-derived — id, channel, address or account id, the sender's name sanitised and marked unverified, a company-domain hint, the message count — and never what they wrote. The block rides in the **user turn**, like the digest, so the cached system blocks are unchanged; the tool definition is static, and the new tool only extends the sorted tool list. The web chat route resets the trusted set every turn, and a resolved id is removed from it. and `list_people` returns both kinds with `kind` — contacts only on the principal's own verified turn (they are private to the principal; anyone else gets the team as if no contact existed) — while `lookup_person` searches the team only. `ask_about_person` reads a person's peer memory fully only on their own verified turn; anyone else's answers only to a verified principal or teammate, and only when that person turned on \"Share my work style with the team\", framed to how they work and never their view of a colleague, and otherwise returns the same \"hasn't chosen to share\" result whether or not anything is known. A teammate's own mail with the Executive is theirs the same way: every mailbox call the model makes through the MCP gateway (Gmail or Outlook, by `call_tool`, from a script, or a workflow step run from chat) passes through `orchestrator.teammate_mail`. A search excludes every other teammate's address server-side, except on mail the reader is also on, and is filtered again entry by entry; any other call that names a message or thread (read, attachment, reply, forward, label) must use an id a filtered search returned to the same reader in this conversation (`Session.mail_ids_seen`) or the inbound email the turn answers; reads are filtered message by message, so a message naming another teammate is left out unless the reader is in its From / To / Cc; a reply that would quote such a thread, or an Outlook forward, reply or attachment read of such a message, is refused; and a conversation in which a teammate other than the principal was shown their own mail becomes theirs alone, as Act as me's do. Outside mail and the reader's own are untouched; an email turn reads as its sender only when Gmail authenticated it, and any other unverified reader sees no teammate's mail, a background run the model drives reads as the principal and a workflow step outside any session as nobody, an Outlook inbox rule that forwards or names another teammate is refused, and the poller and the brief, which read the mailbox themselves, are unaffected. Their own chats are theirs too: every audit row of a teammate's verified turn is theirs (`people_tools.teammate_turn_owner`, see Audit; the principal sees the actions, never the words), and a teammate's web chat or DM opens for them alone (`chat._session_access`), the principal included. The same verified-principal rule, as `people_tools.contacts_reachable_now()`, decides whether a turn may reach a **contact** at all: `message_person`, the raw DM tools, `create_calendar_event` / `create_instant_meeting` attendees and the MCP gateway's Gmail / Calendar / Drive / OneDrive gates treat a contact exactly like an unknown person on every other turn, so no refusal reveals that one exists. **The untrusted-content policy** (`orchestrator.content_trust`) is one rule for text the principal did not write — inbound email, chat, attached files, watched pages — answering three questions for every surface. *Who is speaking*: the principal only when the surface proved it (`principal_speaking`: the web chat as the principal — never a signed-in email on nobody's People entry —, the CLI, their own Slack/Discord or a secret-verified Telegram chat, or their own address with Gmail's `dmarc=pass`); never a teammate, a stranger, Google Chat, the MCP server or an unattended run. *What may become memory*: episodic extraction runs only then, and only on the words outside `` blocks; peer memory and open loops are recorded under the speaker's own name, never as the principal's. *What may trigger tools*: every write on the principal's authority already refuses others in its handler; the tools with no such check that change the install for every later turn — `content_trust.PRINCIPAL_ONLY_TOOLS`: `load_mcp_server`, `list_saved_tools`, `run_python_job` and `run_script` — are not offered unless the principal is speaking on an interactive surface (not email, even authenticated), and a call the model emits anyway gets an error tool_result and a `refused: not_principal` audit row. So an inbound email, a teammate's turn, Google Chat and the scheduler's proactive trigger each get a stable tool list of their own without it. A send or edit on a team member's turn also meets their department's authority first (`orchestrator.department_gate`, see Org), and so does one on a turn whose speaker is on no People entry: a signed-in web caller or a chat-channel sender, which in practice is Google Chat, since Slack, Telegram and Discord turn unmatched senders away. Google Chat names a matched sender to the gate alone (`gate_sender_person_id`), so the principal there is not held. Email turns are left to the mailbox's own gates. *How it is labelled*: outside text goes into the user turn inside `` with a fixed notice that it carries no authority; compatibility forms are folded (NFKC), control characters go, and the tag's name is renamed wherever the text carries it — any case, spacing or entity-escaped form — so the text cannot end its block and speak outside it.\n\n**Outside content** (`orchestrator.outside_content`). Once a turn has read text from outside the team (any gateway read, an attachment, a web search result, a synced Drive, OneDrive, Confluence or Notion excerpt, `read_document`, research, or an extension tool registered `reads_outside`), the rest of that turn refuses what the Act as me lockdown refuses, every `upsert_person` but a contact the speaker named in this message at an address they typed or one that sent the mail read (`lockdown.speaker_named_contact`; never on a turn answering mail from someone not in People, and a refused one points to an `add_contact` card), and every `call_tool` but a read: no mail, Docs, Sheets, Drive or calendar write. The company's own Drive is not outside: a Google read of one file, Doc, Sheet, deck or folder by id (`company_read_item`) is looked up first (`vet_reads`, once per id a turn), and when that item is owned by someone at the company, or kept on a shared drive only the company organizes, and shared with nobody outside it (`mcp_gateway._company_item`), the read marks the turn as a company read (`COMPANY`) instead. That still holds everything else, since anyone who can edit the file wrote its words, but when every read so far was of the company's own items, a rename or move (`update_drive_file` with only `file_id`, `name`, `add_parents`, `remove_parents`) goes through, so the turn can file what it read. Searches, mail, calendar and forms mark it as usual. A refused rename or move the person asked for can go on a `drive_files` approval card instead. A round that reads also holds that round's other such calls; the read itself runs. Scripts are checked call by call. The offered tools never change, so the cached prefix stays the same, and the hold ends with the turn. Mail from someone not in People (a contact or a stranger) counts as outside content from the start of its turn (`Session.sender_off_team`), so the whole turn is held, with no direct messages or watchlist removals either; it can still raise an alert, private to the principal, which is how that mail reaches them. Someone added to People in a turn (or given a new address or chat account) is not emailed, messaged, invited or shared with until the next one. What a turn held is remembered for the conversation's next turn (`note_held`, in process, for an hour). It is kept per conversation and speaker. When that speaker's next message is a go (a short yes such as \"go\" or \"yes, do it\", or one opening with go, go ahead, do it or proceed as its own clause: `GO_RE`), the first round that reads has its reads refused once (`defers_read`, with an error that names no tool and asks for nothing new), so the steps the person just approved run before a new read holds them again; from the next round reads run as usual. It runs only what the model calls in that turn, under every usual check. A shared chat, and a turn answering mail from someone not in People, remember nothing.\n\nHow much a read holds is the owner's setting, **When I read outside content** (`orchestrator.outside_mode`, one row in `outside_content_mode`, changed only by the principal through `GET`/`PUT /executive/outside-content` on Settings → Your Executive), fixed per turn in `begin_turn` (`TurnOutside.mode`). **Ask every time** (`ask`) is the hold above. **Only for outsiders** (`outsiders`, the default) skips what came from the team: a Gmail search that shows only ids and links, a Gmail message read where every message's sender is a teammate (not a contact) and Gmail authenticated it (`fact_confirmation.sender_authenticated` on a raw read of each id, at most 10) and no other address appears, and a calendar read naming only the team where the Executive itself organized every event (a teammate's invite counts as outside, since its organizer is whatever the mail that brought it says) (Google's `get_events` only when `detailed` or for one event, since the plain listing doesn't say who made each event; Microsoft 365's calendar reads) (`mark_read` / `from_the_team`, checked on the result; an `@` the address pattern can't read counts as a stranger). Attachments and synced folders (a file carries whoever wrote it, not whoever sent it), web pages, fetched documents, vendor apps, Outlook mail and every other gateway read still hold. **Don't ask** (`trust`) applies only on the principal's own turn on an interactive surface that isn't email or a shared chat (`content_trust.principal_only_withheld` empty): no read marks the turn, and reading their own mail holds nothing either (the Act as me lockdown and `app_control` are skipped via `trusted_turn`); anyone else's turn gets `outsiders`. Whatever the setting, a shared chat, another AI app's turn and mail from someone not in People are `ask`, sends still go only to people on People, and someone added this turn is reached no earlier than the next one. An unreadable setting is `ask`.\n\nA turn about the principal's private mail (`Session.private_to_principal`) reaches the principal only. It is neither offered nor allowed to run `schedule_tools.PRIVATE_TURN_WITHHELD_TOOLS` — everything that reaches someone else, publishes where the team reads, or starts work outside the turn: the broadcasts, meeting bookings and cancellations, `run_workflow` and research, follow-ups and workflow nudges, watches, artifacts, goal updates, saved workflows, skill drafts, and `load_mcp_server` (it connects to any URL, which can carry the turn's content). Through `search_tools` and `call_tool` it reaches only `schedule_tools.PRIVATE_TURN_MCP_TOOLS`: Google Workspace reads (mail, calendar, and Drive, Docs and Sheets search and file reads, with their Microsoft 365 twins; OneDrive's `download-bytes` only for a file's content) and the Gmail send and draft the gateway narrows to the principal. A search shows it nothing else, and a call to anything else (Google Chat, a Docs or Drive write, `manage_event`, another server) is refused (`schedule_tools.private_turn_withholds`). A call the model emits anyway, MCP tools included, gets an error tool_result and writes an audit row, and every audit row the turn writes is the principal's alone.\n\nA turn another AI app asks over the MCP server (`Session.from_mcp_client`, set by `ask_executive`) reads and advises but never acts, unless the request may act (below). It is neither offered nor allowed to run `schedule_tools.MCP_CLIENT_WITHHELD_TOOLS`: the private-turn and unattended sets, plus every direct message (`message_person`, the Slack, Discord and Telegram DMs), alert acks and new alerts, brief edits, closing open loops, roster and department-head writes, watchlist changes, workflow drafts and form proposals. It gets no Act as me, `recall_history` or extension tools, and of the gateway tools only `schedule_tools.MCP_CLIENT_MCP_TOOLS`: the private-turn reads without the Gmail and Outlook send and draft tools. The set is fixed, so the variant keeps a stable cached tool prefix. A call the model emits anyway, through `use_tool` or `call_tool` included, gets an error tool_result telling it the conversation comes from another AI app, so it can read and advise but not send, post, book or change anything, and writes an audit row (`refused: mcp_client`); `MCPGateway.call_tool` refuses the same calls again.\n\nA request may let that turn act (`Session.mcp_client_may_act`: the `x-mcp-client-actions: on` header on a request the shared secret authenticated, or `MCP_CLIENT_ACTIONS` where no secret is set). It then runs the speaker's own changes inside Open Executive (`schedule_tools.MCP_CLIENT_RUNS_WHEN_ACTING`: alerts, open loops, follow-ups, the watch list, goals, decision outcomes, facts and company profile writes; never the roster), each under its own owner-only and person checks, since the turn counts as a verified surface for its caller. It is still denied a fixed set, `MCP_CLIENT_ACTING_WITHHELD_TOOLS` (DMs, calendar writes, broadcasts, `load_mcp_server`, scripts, Python jobs, workflows, research and the rest of the read-only set), with a refusal telling the person to do it in Open Executive. A message or a meeting goes on an approval card instead (`propose_actions`), which is carried out at once when the person's Handle it for me covers it, everyone on it has been on the roster for a week, and `action_cards.on_its_own_refusal` finds nothing against it, and otherwise waits for their tap. Every audit row it writes names the app (`via`).\n\nA web chat its owner shared (`Session.shared_chat`) mixes people. The note naming who is in it says which of them owns it and that the others' lines are messages from them, not the owner's instructions. While someone other than the owner has written since the owner's last message (`Session.others_spoke_last`, set per turn by the chat route from the saved history, the speaker included, and true when the history can't be read), the turn is neither offered nor allowed to run `schedule_tools.SHARED_CHAT_WITHHELD_TOOLS`: direct messages, the broadcasts, meeting invites and cancellations, follow-ups, roster writes and workflows (a run, a save or a suggested schedule acts later with the full tool set). Through `call_tool` it can't send, reply, forward or draft mail in Gmail or Outlook, or make a calendar change that mails attendees (`schedule_tools.shared_chat_withholds`). That holds on the owner's own turn right after someone else's line too, so a request carried over from it can't send; the owner's next message with nobody else's in between lifts it. A call the model emits anyway gets an error tool_result and writes an audit row (`refused: shared_chat`). The principal-only tools stay out of every shared chat (`content_trust.principal_only_withheld`).\n\nOn every turn the Executive knows what is connected without searching: block 0's *Connected Systems* section names the connected Google services, other MCP servers, the messaging channels, Notion sync, Confluence sync (when on) and Act as me, and pins the Google tool names it calls directly with `call_tool`. Both the service list and the pinned names are narrowed to what workspace-mcp actually loaded (`WORKSPACE_MCP_TOOLS`, `WORKSPACE_MCP_TOOL_TIER`), so a service left out is not named and its tools are not pinned — being told a tool is already available when it is not in the index costs a failed call the model cannot explain to itself. `search_tools` is for anything not listed. When work repeats over many items, the Executive can also call `run_script` (`workflows/step_script.py`, offered when the gateway runs, unless `CHAT_SCRIPTS=false`, and only on the principal's own turns — `PRINCIPAL_ONLY_TOOLS`, withheld on an unattended pass too, because it spends the turn's whole call budget on code the model just wrote): one short Python script, run in a sandboxed Monty worker with no files, network or environment, whose only reach is the gateway tools `call_tool` could reach (those `search_tools` has returned; the gateway keeps that set for its own process-wide session, not per conversation). Each call it makes is a `call_tool` in every way that matters — the gateway's discovery, deny-list and recipient gates, Take the lead's gate on an unattended run (or the department gate on a team member's turn, see Org), and the same action chip and `tool_invocation` row (marked `via: run_script`, `ok: false` when it raised), as each call happens; the script itself gets a `kind: script` row with its source and result. Its wall clock is half the turn's deadline, so a long script ends with the list of calls it made rather than being cut off with the turn, and a stopped turn closes it and its worker. A turn private to the principal is not offered it, and the Act as me lockdown withholds it whole. Besides gateway tools, a chat script may call a fixed list of the Executive's own tools (`step_script.CHAT_OWN_TOOLS`: reads, alerts, open loops, goals, the watch list, follow-ups, `message_person`, `upsert_person` — nothing that broadcasts, books calendars, edits memory or skills, or starts workflows or research), each only when the turn is offered it and through the turn's own handler (Take the lead's gate included), with the same chip and a `kind: skill`, `via: run_script` audit row. A direct call needs a `tool_use` each, so a turn's scripts share a budget instead: `CHAT_SCRIPT_MAX_CALLS` (default 200) calls in all, and per-turn caps on own tools that could otherwise act in bulk (`CHAT_OWN_TOOL_CAPS`: `message_person` 10 — messaging the whole roster would be a broadcast — `upsert_person` 25, `create_alert` 50); a refused call tells the model to say what is left. A call stopped mid-flight by the script's clock or the turn still gets a `cancelled` audit row, since it may have run. Because every round re-reads the conversation, the loop also nudges at the moment of choice: after a round whose results include a list of five or more items (`step_script.lists_many`), with `run_script` on offer and not used that round, it adds the constant `FANOUT_HINT` as a text block in that round's user message (never a cached block), once a turn, pointing the per-item work at one built tool. For library work on files (PDFs, spreadsheets, charts, images) the chat also has `run_python_job` (see Workflows → Python jobs): Python with a fixed library set in a Deno-isolated WebAssembly sandbox, fed Knowledge files by name, the message's own attachments as sent, or text it already holds, and able to keep a job that worked as a Custom tool, with result files linked for the principal to download; it is offered only on the principal's own turns and only where the sandbox is installed (not offered otherwise, so the tool prefix stays fixed per process). Agent Activity shows each run as one \"Built tool\" card (debug event `script_run`: ok, the calls it made, how many, how long, and the kept or reused tool's name). The persona's tool-building section is present only when chat scripts can actually run (the setting and Monty installed). `TOOL_BUILDING_ADDENDUM` in the cached persona tells the Executive when to build a tool — a job that looks something up and then checks or acts on each result, paged results, more than about ten items, or an answer that needs three or more reads (find, open, read; the tool returns only what answers the question, not whole documents, and only what is about to be edited or quoted at length is read in full), while a handful of items already named go as direct calls in one turn — and never to say it has none, and to talk about it as building a tool or doing it in one go. On the principal's own verified, interactive turns, a script that works can be saved as a named tool (`save_as`; elsewhere the script runs but is not saved) and run again with `run_script(tool=…, inputs=…)`; `list_saved_tools` (a constant definition, in `PRINCIPAL_ONLY_TOOLS` with `load_mcp_server`, so offered only while the principal is speaking on a verified, interactive surface; `run_script(tool=…)` is refused on any other turn) lists them, and each call a saved tool makes is a gateway call like any other (see Workflows → Saved tools). The tool's definition is a constant, so the cached tool prefix does not move. Multiple parallel `tool_use` blocks in a single assistant turn fan out concurrently through `router.route_parallel`, each running its own domain-filtered ChromaDB query (3 built-in and 2 company chunks by default, see the RAG section) before the specialist sees the question. The fan-out is failure-isolated per call: a specialist that raises or returns no text becomes an `UNAVAILABLE` tool_result (`router.specialist_unavailable_result`) instead of failing the whole turn, and its area — \"finance\", \"legal\", never the specialist's name — is reported to the user under the web reply, or by the Executive in its reply on other channels, unless another call to it answered that turn. A failed specialist does not count as consulted, so it takes no committee seat. Cancellation (Stop) still propagates. The Executive then synthesizes the returned analyses into one reply.\n\n`remember_fact`, `forget_fact` and `update_company_profile` (`orchestrator/fact_tools.py`; the last two sit in the `memory` group of `open_tools`, since they are rarely needed) make a correction stick: the first two keep or retire a standing fact in `memory.facts`, and the third changes one field of the company profile. They share `record_decision_outcome`'s gate (the principal on a verified surface, an audited refusal for everyone else) and are in `UNATTENDED_WITHHELD_TOOLS`. `remember_fact` alone also admits a rostered teammate on a verified surface (web signed in, their own Slack or Discord): the fact is stored attributed (`recorded_by_role=\"teammate\"`, rendered `(per )`), and is held as a `proposed` row (`awaiting_approval`) until the principal approves it, unless the principal marked that teammate trusted (\"needs my approval\" off; on by default) — and even then when it would replace the principal's fact. All three are also in `PRIVATE_TURN_WITHHELD_TOOLS`, since what they write or retire is read on everyone's turns. On the principal's own authenticated email (primary address, with Gmail's own `Authentication-Results` reporting `dmarc=pass`, not forwarded) the three tools check the request as usual, then hold it as `awaiting_confirmation`. It applies only when the principal replies to a one-time token emailed to their own address (`integrations.fact_confirmation`); the split into a check step and an `apply_*` step lets chat and the confirmed reply share one apply path. Each write also needs a `source_quote` found in the speaker's own words this turn. The active facts reach every specialist as a `` user-turn tag: `route_to_specialist` reads them (None → the store, \"\" → none) for chat consults and workflow steps alike, and `BaseAgent.analyze` places the tag next to ``, never in the cached specialist system prompt.\n\nThe ten entries in `SPECIALIST_REGISTRY` (`openexecutive.orchestrator.router`):\n\n| Key | Agent | Domain |\n| --- | --- | --- |\n| `cso` | `StrategyAgent` | strategy |\n| `cfo` | `FinanceAgent` | finance |\n| `chro` | `HRAgent` | hr |\n| `gc` | `LegalAgent` | legal |\n| `coo` | `OperationsAgent` | operations |\n| `cmo` | `MarketingAgent` | marketing |\n| `cpo` | `ProductAgent` | product |\n| `sales` | `SalesAgent` | sales |\n| `board_comms` | `BoardCommsAgent` | board |\n| `triage` | `TriageAgent` | triage |\n\nModels: specialists default to `claude-sonnet-5-5`; the deep-reasoning tier (CSO, CFO, GC, Board) runs on `DEEP_REASONING_MODEL`, `claude-sonnet-5-5` by default, with adaptive thinking; routing/fast classification uses `claude-haiku-5-5`. Requests are fitted to the model's generation on the way out (`feature_gate.fit_claude_generation`): 5-generation Claude models never receive `temperature` / `top_p` / `top_k`, and Haiku 5.5, which thinks unless told otherwise, gets thinking turned off when the caller asked for none. The Council's **Deep reasoning** toggle is honoured on Claude Opus/Sonnet (direct or via OpenRouter) and on any OpenRouter model whose live catalog entry supports `reasoning` (today all OpenAI, Gemini, DeepSeek and Grok entries; not Llama) — the request translator turns Anthropic adaptive thinking + effort into OpenRouter's `reasoning` parameter. It is a silent no-op on models that can't reason, and disabled for Haiku, which rejects adaptive thinking. A PDF `document` block is translated too: it becomes an OpenAI `file` part, and OpenRouter requests carrying one add the `file-parser` plugin (`native` for models whose catalog entry lists `file` input, else `PDF_OPENROUTER_ENGINE`, default `mistral-ocr`). `FeatureSpec.supports_pdf_input` gates it per model — off for a local server unless `LOCAL_PDF_INPUT` — and a gated-off PDF becomes a text note rather than vanishing. The Council picks a model in two steps: a **Provider** dropdown (Anthropic, OpenAI, Google, Meta, DeepSeek, xAI, Local, …), then a **Model** dropdown limited to that provider, with readable names (\"Claude Opus 5.5\") and the raw id and serving route shown underneath. The Anthropic group combines the direct Claude models (`claude-fable-5-1`, `claude-opus-5-5`, `claude-opus-5`, `claude-sonnet-5-5`, `claude-sonnet-5`, `claude-haiku-5-5`, `claude-haiku-4-5`) with any Claude generation that exists only on OpenRouter, and never lists a model twice. With `OPENROUTER_ENABLED`, the non-Anthropic entries come from OpenRouter's live `/models` catalog (fetched at startup, refreshed in the background, filtered to tool-capable paid text models from an allowlisted vendor set), falling back to a built-in snapshot if the fetch fails. Every Council agent also takes **additional instructions** (`agent_overrides.instructions`): text appended after its prompt by `agents.overrides.append_instructions` inside an `` block. `BaseAgent.base_system_prompt()` is the built-in prompt or its full replacement (what the Council's editor shows); `effective_system_prompt()` is that plus the instructions (what every call sends), and the Executive gets the same text through `build_system_blocks(persona_instructions=…)`. Unlike a replaced prompt, instructions leave the agent on its built-in prompt, so prompt improvements keep reaching it. A **Quality** choice above the agents (`agents.presets`) sets every agent's model and deep reasoning at once: **Fast** puts every agent on a small model (`claude-haiku-5-5`, else `claude-haiku-4-5`) with deep reasoning off, **Balanced** returns each agent to its built-in defaults, and **Thorough** puts the Executive and the domain specialists on the largest allowed model (`claude-opus-5-5`, else `claude-opus-5`) with deep reasoning on where the model supports it, leaving triage and the helpers below on their defaults. Presets change quality only; they never cap spend. Each preset resolves against the install's model allowlist at request time and is offered only when one of its models is allowed. Applying one writes ordinary per-agent overrides (model and deep reasoning only), so history, rollback and later per-agent edits keep working; an agent changed on its own afterwards shows as **Custom**. Each Council agent has a **visibility**: `core` for the Executive and the domain specialists (`BaseAgent.visibility = \"core\"`, which a new specialist must declare) and `internal` (the default) for triage and the helper agents below. Thorough moves only core agents. The Council opens in a **simple view**: the Quality choice, the voice picker, and only the core agents, each with its additional instructions and a model badge. **Show all agents** lists the internal ones too, and **Advanced** opens the full page (the browser remembers the choice). Eight Council-only agents are model/prompt-overridable in the UI but NOT callable via `consult_specialist`, because they are deliberately kept out of `SPECIALIST_REGISTRY`: `quality_judge` (committee review), `utility_fast` (gates, titles, decision parsing), `research` (the `executive_research` fan-out), `fixture_generator` (invents demo companies), `engagement_intake` (models a real client from intake material), `onboarding_interviewer` (interviews a new user about their own company and drafts its profile, people and departments), `workflow_designer` (runs the conversational New workflow wizard on `/jobs/new`: searches the available tools, asks clarifying questions, then drafts a validated dynamic-workflow definition for the user to review), and `workflow_actor` (runs a workflow's `action` steps: a bounded tool-use loop limited to the tools the user approved for that step). `fixture_generator`, `engagement_intake` and `onboarding_interviewer` share one shape — a forced tool call producing a whole company, validated for referential integrity, with a single repair retry — but opposite epistemics: the fixture author invents, the other two extract and leave unstated facts empty rather than guessing. When the workspace is in solo mode, `onboarding_interviewer`'s first user turn is prefixed with a short hint (`interview.solo_hint(role_kind)`, one static constant per role kind from the onboarding role step): the draft names only the principal, as principal, and no departments; an owner is asked about the offer, customers, pricing, cash and runway, an in-house executive about their organisation (drafted as the company — their employer), function, remit, manager, team size as context and what they are measured on, an independent about their practice, clients and pricing, and with no role given the role-neutral `SOLO_HINT` asks which they are. The hint rides in the user turn and the tool array is unchanged, so the cached system prompt and tool prefix are the same in both modes.\n\n**Voice.** The Executive answers in one voice persona, substituted into block 0 at `{VOICE_PERSONA}` (`personas/loader.get_active_body`) from the Executive override's `voice_persona_slug`. Three general voices ship in `personas/builtin/`: **Direct** (`default`, the slug used when none is set: answer first, brief and decisive), **Supportive** (a warm coach who explains the why and checks how the plan sits) and **Analytical** (numbers first, options and trade-offs, then one recommendation). They are offered by a shared picker in setup (`/onboard`, after the workspace choice) and in Settings → Executive, which saves through `PATCH /agents/executive`; a built-in voice's frontmatter carries the picker's one-line `description` and `sample` reply. The named-executive voices are marked `legacy: true`: the picker hides them, but they still load, so an install that picked one keeps it, and the Agent Council lists them under \"Legacy voices\". Voice files hold disposition only; the response-length ladder lives in `prompts/executive_persona.py`, and a test keeps numeric length rules out of every voice file. An eval scenario may set `voice_persona` to answer in a voice; it rides on `Session.voice_persona_slug`, which wins over the override for that session only.\n\nOne more structured-output utility agent sits beside `triage`: `openexecutive.agents.alert_review.AlertReviewAgent` (routing model, one forced `emit_alert_reviews` tool call, never raises). The alert review job hands it a batch of open alerts with evidence and gets back, per alert, a verdict (relevant / changed / resolved / stale), a confidence, the evidence it relied on, a one-line note, why-now, an optional deadline, and a recommended move (none / route / nudge / escalate / draft / suggest_workflow / merge / close) with its parameters. Like `triage`, it never mutates state — `alerts.review.apply_verdict` executes the verdicts deterministically within authority, so the model's job is judgment, not action.\n\n**Solo mode.** When the workspace (or the turn's session) is solo — one person using Open Executive for themselves — the Executive works for that one person, the principal, whatever their role: they may run their own business, lead a function inside a larger organisation, or work independently, and the persona reads which from the context instead of assuming it — the role lines under \"Your Principal\" in the org block, when the principal gave their role (workspace settings: kind, title, reports to, remit, measured on). Its persona swaps the team sections (\"You Are a Member of This Executive Team\", \"Choosing Who to Tell\", \"Departments & Org Coordination\") for \"You Work for One Person\", \"The People in Their World\", \"Who Hears From You\" and \"Goals and Areas\". Solo means only the principal uses Open Executive — not that they have no people: their manager, direct reports, peers, clients, investors and board are contacts. The Executive helps the principal work with them (a 1:1 or review prep, an update or budget case for their manager or board, a negotiation) but does not coordinate them, contacts them only when the principal asks and only if they are a contact, and never offers to loop the principal in. Every specialist stays relevant — HR for the principal's own reports, board and investor communications for reporting upward. Goals are grouped by *area* (the tools' `department_slug` is the area's slug). The chat loop withholds `send_department_message`, `send_company_broadcast` and `set_department_head` from its tool list, and a call to one of them anyway gets a JSON error tool_result instead of running. The mode is resolved once per turn and pinned on the session, so the persona, the org block, the tools and every tool handler agree — a switch made mid-turn applies from the next turn. Team mode is unchanged.\n\nEvery specialist *advises* — each returns domain analysis the Executive synthesizes into one voice. None of them act on the company's behalf; the side-effecting tools in the main loop (schedule, people, departments, broadcast, watchlist, workflows) belong to the Executive, not to a specialist.\n\n`recall_history` (Always in the loop, `orchestrator.history_tools`) reads the speaker's own notes, on the same terms as `ghostwrite_email` below: its own registry, offered only on a verified turn in a conversation private to them with their switch on; its `due` flag lists only dated notes, soonest first (see Memory). The ghostwriter below also gets the writer's notes from mail every recipient was on, as a consistency check (``).\n\nThe morning reflection, which has no question to look up and no specialist to consult, gets one more read-only tool of its own: `search_knowledge` (`orchestrator.knowledge_tools`), the same knowledge lookup a chat turn makes, for what the company's documents say about a customer, contract, price or policy before it acts.\n\nA fifth, `remind_me` (`orchestrator.reminder_tools`), sets a plain-text reminder sent to the speaker alone when it is due; it opens no mailbox, so it never locks a turn. A sixth, `propose_actions` (`orchestrator.action_card_tools`), does nothing itself: it leaves the speaker a card of up to five actions (a meeting, a message, a new contact) that happen only when they tap Approve, so it stays on after a turn read mail; it is where what an email itself asks for goes, since that is never done on the email's say-so. Four read-only tools beside it read the speaker's own mailbox on the same terms — `search_my_email`, `read_my_email`, `read_my_email_attachment`, `my_email_awaiting_reply` (`orchestrator.mail_read_tools`) — and `my_calendar` their own calendar while Read my calendar is on, capped per turn, other people's messages and attached files returned as untrusted blocks (see Act as me). Beside them, `save_my_email_attachment` copies one attached file, when the speaker asked in their own words, into a fixed folder of the Executive's own Drive or OneDrive and shares it with them alone, every call built in codeOne tool writes under someone else's name, and it is fenced in code rather than the prompt. `ghostwrite_email` (**Act as me**, `orchestrator.delegation_tools`) writes an email as the person the Executive is speaking with and saves it as a draft in their own mailbox, Gmail or Outlook (`delegation.gmail.gmail_for` picks the client by the person's credential); chat never sends it (the person sends it themselves, or taps Send on its card). It lives in its own registry, never `_ALL_SKILL_TOOLS`, so reflection, research and workflow toolkits can never carry it. The chat loop adds it only on a turn `delegation.settings.pin_turn_delegation` offered it to — the principal, or a team member once the owner lets them, with it on, verified, in a conversation private to them (the web chat, a Slack or Discord DM, a verified private Telegram chat), on an attended turn — and builds that turn's handler map from both registries, so on any other turn a call to it is an unknown tool. From the round that calls it until the turn ends, the dispatch guard refuses only what opens an outside address, runs a script or workflow, or posts to everyone (`delegation.lockdown`), without changing the offered list; messages and invites to the roster, follow-ups, facts in the person's own words and a contact they name still run, each fenced in its own path. A later turn in the conversation stays private to its owner but holds nothing: the person asking again, having read the reply, is their own request. A contact add the lockdown refuses becomes an approval card in the chat instead (`propose_actions`). Recipients are chosen in code, never by the model (a draft's new email may go to any address, named back to check when it is neither on the roster nor typed), and the text comes from the **ghostwriter** (`delegation.ghostwriter`), a tool-less call with a constant prompt plus the person's \"How I write\" profile. The ghostwriter is deliberately not a Council agent: the Council's edit routes have no principal check. See **Act as Me (Delegation)**. A second tool joins the same way: `recall_history` (`orchestrator.history_tools`, Always in the loop) reads the speaker's own notes of what they said, by email or in chat. It has its own registry and per-turn handler map too, and the loop adds it only when `history_tools.recall_person` finds the speaker verified, in a conversation private to them, on an interactive turn that isn't private to the principal, with their notes switch on. It doesn't need Act as me. After every chat turn the Executive also hands the speaker's own words to `history_chat.schedule_chat_notes`, which takes notes in the background when their switch is on and the surface verified them (see **Memory**). A meeting `create_calendar_event` proposes works the same way when the person chatting is the one who approves it: its chip carries the `decision_id` and the chat shows the meeting card (Approve, Edit, Dismiss) under the reply, while the briefing keeps it too. A tool result held for someone else's approval (`waiting_for_approval`, the department gate) gets a *Waiting for approval* chip, never a done one.\n\nThe Executive can also launch *any* workflow conversationally: `orchestrator.workflow_run_tools` adds `list_workflows` (the launchable catalog with each workflow's inputs) and `run_workflow` (runs a built-in or custom workflow to completion and returns its artifact, checkpointing approval gates as `awaiting_human`). `run_workflow` runs the weekly review, and in solo mode the morning brief, only for the principal on a verified surface (`Workflow.principal_only_modes`, `create_goal`'s rule); anyone else gets an audited refusal that does not name the rule. A run whose `result` event reports `private_to_principal` (the morning brief on the principal's own verified turn, when it read their private rows) still returns its artifact into that turn, but the stored run keeps `persistence.PRIVATE_RUN_ARTIFACT` instead of the text, since run history is readable by everyone signed in. This makes the `/jobs` catalog reachable from chat — see the Workflows section.\n\nDeliverables are first-class too. `orchestrator.artifact_tools` gives the Executive three tools: `draft_artifact` publishes a memo, web page (`html`), Word document (`docx`), spreadsheet (`xlsx`, from structured `sheets`) or a `link` to something it created in one of the principal's connected apps through the generic MCP gateway (`search_tools` → `call_tool`, any server — no vendor is named), and returns the artifact's id and link; `list_artifacts` and `get_artifact` let it find and reread its own past work (drafts and workflow outputs alike, through the same `artifact_records` read model the API uses). Every artifact belongs to someone: a draft to the person whose turn published it (`artifact_records.current_viewer`; with nobody speaking, the scheduler or the CLI, the principal), and a run someone started by hand to them, while scheduled runs are the team's. The tools, the recall, `message_person`'s link and mail attachments only ever reach the speaker's own (a link is refused when the recipient couldn't open it), not even the principal sees a teammate's, and a speaker who isn't on the People list publishes nothing. A workflow's own session inside someone's turn (the research synthesis) is pinned to that turn's viewer (`Session.documents_viewer`). Revising is `draft_artifact(supersedes=)`, which archives the earlier version. When the principal asks for a deliverable the tool is always used; the \"quiet is the right default\" bar applies only to unprompted drafts. The format enum is a hard-coded sorted tuple so the cached tool schema stays byte-stable. Delivery: `message_person(person_id, text, artifact_id?)` appends the artifact's title and a `UI_BASE_URL/artifacts/` link (through the same outbound guard as any DM; the link needs Open Executive access), and a Gmail send/draft can attach the file itself by `artifact_id` — see Integrations.\n\n**Extensions** (`orchestrator.extensions`). An install can add chat tools and kinds of document without changing this code. `OPENEXECUTIVE_EXTENSIONS` names Python modules. The first turn, or the first Documents read, imports each one and calls its `register()`. A module that fails is logged and skipped. `register_tool` takes an `ExtraTool`: a definition, an async handler, an optional `tool_groups` group (new or existing) and an optional chip. It refuses a name the loop already knows (`executive.builtin_tool_names`). An extension tool is never in `_ALL_SKILL_TOOLS`, so reflection, research and the synthesis never see it. The chat loop adds it per turn, like the Act as me tools, but never on an unattended run, a turn private to the principal or to a speaker who isn't on the People list (mail from a stranger), and only where its own `offered(session)` says. That predicate depends only on the kind of turn, so each kind of turn keeps one cached tool list. Act as me's lockdown refuses it once the turn has read the owner's mail, because it is unclassified and the lockdown fails closed, unless it was registered with `after_mail_read=True`: a tool that only reads the install's own data and carries nothing the model wrote anywhere outside. Its chip goes through `action_chips.summarize_action`, which applies the same no-chip rules for an error or refusal and keeps a link to a path inside the app. `register_collection` adds a kind of document: a drafted artifact tagged `collection:` carries that collection on its record. The Documents page gives each collection its own filter. The collection's `on_change` hears about archive, restore and delete before they happen; if it raises, the change does not happen. `draft_artifact(supersedes=…)` refuses a collection's document. The alert pipeline strips `collection:` tags, so only the extension's own code files a document in its collection. A module that fails partway through `register()` leaves nothing registered. A tool registered with `workflow=True` is also offered to workflow action steps as `oe__` (see Workflows); `read_only=True` marks one that only reads. A step runs with nobody in the turn, so `offered` isn't asked: `extensions.in_workflow_step(name)` is True for that tool, during its call only, and the handler decides what an unattended call may do.\n\nDocuments come in the other way through `read_document` (`orchestrator.document_tools`): it reads a PDF, Word, Excel, CSV, Markdown or text file by `path` — one another tool saved, such as a Gmail attachment (`get_gmail_attachment_content`) or a Drive file (`get_drive_file_download_url`) — or a company document by `filename`, with an optional PDF `pages` range. A scanned PDF is converted (`knowledge.pdf_reader`: local OCR by default; with `PDF_PROVIDER_READING=true`, the deployment's own model through its provider's PDF support, falling back to local OCR) rather than returned empty. Reads are confined to the tool download folders — the same resolve-then-contain check as `oe__read_file` — plus the company docs folder, and it is withheld on turns private to the principal (`PRIVATE_TURN_WITHHELD_TOOLS`): those come from a contact's email, whose own attachments the poller has already read into the turn, and must not steer the Executive into company documents.\n\nThe Executive also backs the **Ask OE** panel's form-fill tier via `orchestrator.form_tools.propose_form_values`: when a chat turn arrives with a `` block describing a form on the user's screen, the Executive can propose values for it. The proposal is delivered to the UI as a `form_patch` SSE event and placed into the form as highlighted suggestions — the tool never saves or submits anything; the user reviews and saves manually. The tool is **always** in the tool list (tool definitions sit at the top of the cached prompt prefix, so a conditionally-present tool would invalidate the prompt cache whenever panel and non-panel turns alternate); its description restricts usage to turns that carry a form descriptor. For questions about the product itself, the always-offered `read_user_guide` tool (`orchestrator/guide_tools.py`) returns any `guide/prebuilt/.json` section; its description lists the section ids, fixed at import, so it never changes the cached tools prefix.\n\n**Take the lead** (`orchestrator.take_the_lead`) is how the Executive acts on its own, behind one gate. It has two switches, both off until someone turns them on. **As the Executive** is the principal's alone (`PUT /take-the-lead`). With it on, the unattended passes (reflection, research, the scheduler's proactive trigger) keep the acting tools solo mode otherwise withholds from them: booking, starting workflows, and messaging teammates rather than only the principal. `schedule_tools.unattended_toolkit` and the chat loop's unattended path then wrap every acting tool (`ACTING_TOOLS`, plus any MCP tool whose name sends, creates, updates, deletes or shares) in `gated_handlers` / `gated_call_tool`. The principal-only tools (`UNATTENDED_WITHHELD_TOOLS`) stay withheld, and every guard inside a handler still applies, including department approval levels (a booking in a department on Proposes is still a proposal) and the contact-egress rule. New mail or chat (any turn with an `origin_channel`) wakes the existing reflection early (`take_the_lead.wake`): one run waiting at most, five minutes after the first message and no more than every 30 minutes, as a one-off `take_the_lead_wake` scheduled action. Pause holds it like any scheduled work. **As you** is the Handle it for me dial's top step (delegation section).\n\n**The gate** is plain code (`take_the_lead.check`). Six kinds of action wait for a yes first, each with an Ask first switch, all on to start. The switch is the decision ledger's own mode for the class `take_the_lead_action:` (`propose` asks first, the default; `auto_execute` doesn't). The kinds are money, contracts and legal, hiring and people decisions, deleting/cancelling/sharing, someone new (an address on neither the roster nor the contacts) and big sends (a broadcast or department message, or more than five people). On top of these, the company (set by the principal) and each person (for their own As you) add structured rules: a person or address, a domain, words, or an amount at or above a figure. Added rules always hold, and an error reading them holds the action. The one thing the gate lets straight through is a direct message (`message_person` or a Slack, Telegram or Discord DM) whose only recipient is the principal (`_only_to_principal`): they are who it would wait for, so a card would only ask them to approve a message to themselves. Anything that reaches or acts on anyone else is checked, and a roster that can't be read is not the principal. A held action becomes a `take_the_lead_action` decision instance with a companion `DECISION_ALERT_SOURCE` card on Today, routed to the first teammate holding one of its People approval ranges (`take_the_lead.approval_ranges`, plain code, closest fit first: money to `spend_lt_2k`, `spend_lt_10k` or `spend_gt_10k` by its largest amount, with a higher range able to approve a lower one and no amount read as large, or to `customer_credit` for credit and payment terms; vendor contracts to `vendor_onboarding` then `legal_sign`, other contracts to `legal_sign`; hiring to `hiring_signoff`; anything else about the board or investors to `board_comms`; in Personal as in team mode), or else the principal through the authority gate's fallback (`departments.authority._route_proposal`). Deletes and shares always go to the principal. Only the principal and that approver see or resolve it (`DecisionClassSpec.principal_and_approver`). Approving it claims the row and carries out the exact call once (`take_the_lead.carry_out`: the unwrapped handler, or the gateway's `call_tool`, on an unattended session, with contact egress only when the principal approves). Declining drops it. Everything done or held is a row in `take_the_lead_log`. What it did and what was approved show in Recent activity (`today._build_activity`, kind `took_the_lead`), named but never quoted, since that feed is the team's. The held card's own `decision_resolved` row is left out because its payload quotes the message. `GET /today/activity` also adds the caller's own Handle it sends (kind `sent_as_you`, actor `you`), which are never in the shared feed.\n\n**In training.** The As the Executive switch has three positions: Off, **In training** and On (`take_the_lead.training`). In training, an action the gate would let through still waits, as a `training` hold for the principal alone, unless the principal has allowed that action with that person or thing (`take_the_lead.allowance`: \"Message Priya Nair\" covers a message to her on any channel; \"Book meetings with Priya Nair and Sam Lee\" meetings with exactly them; a workflow by id; a connected tool by name and the file it names, never one that reaches people such as a mail send or a share). The card carries Approve with a **Do this on its own next time** tick (`allow`), **Edit** (the message, a meeting's title, times or description, a task and its due date: `take_the_lead.apply_edits`) and Decline. Allowing is the principal's alone and needs a request the API can tie to them, as turning it on does; the six kinds and the added rules hold whatever is allowed. An edit approved with \"Do it like this next time\" is kept as an example (only the edited text, the principal's own words), and `take_the_lead.learned_note` hands the examples and the fact it's in training to the reflection and research passes in their user turn. After `SUGGEST_AFTER` (3) unchanged approvals of the same action in 30 days, Settings suggests allowing it. What it's learned is one list keyed by feature (`take_the_lead_allowed.feature`), so other features that learn from approvals can join it; each keeps its own switch. Act as me is the second (`act_as_me`, per person, `delegation.training`), with a training switch per setting (Replies, Follow-ups, Suggested actions, Drafts): its rows carry the person's id and the setting in their key, are listed and removed by that person alone, count toward their own cap, and never reach `learned_note`.\n\n**AI disclosure.** Nothing the Executive sends to people outside the web chat goes without saying it is an AI: plain code adds the line in the MCP gateway (mail) and at each chat send (Slack, Telegram, Discord, Google Chat replies, its DMs and room posts), never the prompt, so the persona and the cached system blocks are unchanged and a reply the model writes can't drop it. Replies the inbox watcher writes as a person carry their own line by that person's switches. See Integrations → **AI disclosure** and Act as me → **AI line on replies**.\n\n**App control from chat** (`orchestrator/app_control.py`, catalog in `orchestrator/app_control_catalog.py`). Three tools in the `app` tool group let the Executive do what the app's buttons do when the person asks (\"pin this\", \"delete that chat\", \"turn off Handle it for me\"): `find_in_app(kind, query)` lists what the speaker may change (their chats, topics, documents, workflow runs, settings; files, memory, departments, skills and workflows for the principal), `do_in_app(action, id, value)` does one action (`id: \"this\"` is the chat they are in), and `open_in_app(screen)` returns an in-app link. Each action calls the API route its button calls, as a function, with a request naming the turn's verified speaker (`correction_tools._speaker`; an email or unattended turn gets a refusal), so the route's own checks decide what this person may do. Actions whose route checks nothing about the caller (their page is the principal's) are `principal_only`. Deletes, shares, turning on Act as me, Handle it for me or Take the lead, removing a Take the lead rule, clearing every card and approving a workflow's waiting step never run in the turn: they become an `app_action` decision card, private to the speaker, shown under the reply; the tap runs the action with the tapper's own request through `POST /decisions/{id}/approve` (claimed once; a card older than a day is turned down). After a turn reads outside text or the speaker's mail, every action becomes a card. The tools are withheld from unattended and AI-app turns, and `do_in_app` from a shared chat someone else spoke in. Snoozing a card uses `ack_alert`'s trust rule: only ids the server showed or found this turn. Extensions add kinds, actions and screens with `register_kind` / `register_action` / `register_screen` at startup. The person's notes stay with `recall_history`; account, sign-in and billing stay on their screens.", + "markdown": "The Executive (`openexecutive.orchestrator.executive.Executive`) is a single coherent persona that fans out to specialist sub-agents via Anthropic tool use. Each specialist subclasses `openexecutive.agents.base.BaseAgent`, carrying a `name`, `domain`, and `model`, and exposes `analyze(...)`, which wraps the question in user-turn context and calls Claude through the per-call provider. That context is per call and never cached: `` (the stage from the company profile — specialists never see the profile itself), in solo mode `` (what kind of principal the advice is for, in plain words, with their title and remit — `router.principal_role_context`), ``, ``, `` (the shipped case studies, and under them the workspace's own lessons that match the call, `memory/lessons.py`), `` and ``, outermost first. The specialist's own system prompt is a static domain constant under one `cache_control` block, so it stays byte-identical across companies and profile edits. A chat turn takes the stage from its session's profile (the one the Executive reasons over, and the one an eval scenario injects); workflow steps and the MCP server's `consult_specialist` read it fresh from the profile on disk. The role tag follows the turn's mode: a chat turn pins the role on its session with the mode (`pin_turn_principal_role`: the session's `principal_role` override, else the workspace settings) and sends it in solo and nothing in team, so the org block, every specialist in the turn and any workflow the turn starts see the same role; callers with no chat turn read it fresh (`load_principal_role`: the current session's mode and role, else the workspace's), and a failed read sends no tag rather than failing the consult. The CFO, CSO and sales prompts key their advice on it: for a bootstrapped, self-funded or solo company they drop venture fundraising benchmarks and work from cash, and raise only if the founder wants to.\n\nRouting uses one `consult_specialist` tool. When to call it is spelled out in the persona's Domain Expertise section (\"Going deeper\") and in the tool's own description: any question that asks for judgment in a specialist's area (a decision, plan, analysis, negotiation, hire, contract term, pricing or fundraising move, board message, or a department's own targets and stance, since the specialist holds that department's memory) is consulted before the answer, several at once across areas; lookups in mail, calendar or documents, scheduling, status checks, routine drafting and questions about how the Executive works are answered without one. Chat eval scenarios hold this: `required_routing` names specialists the turn must consult and `no_routing: true` says it must consult none, and the runner fails a scenario that misses either (`evals.scenarios.routing_check`). Beside it the Executive carries two skill tools, `search_skills` and `load_skill` (skills are shown to users as **playbooks**, and every playbook belongs to a workflow: one that follows it, or its own Quick workflow). Chat finds and reads playbooks but never creates or changes one: work worth repeating is offered as a workflow through `draft_workflow`, with the method written into its steps, because workflows read playbooks at run time (approved scheduled ones included) and this loop also runs on inbound email and chat, so only a person can change what a workflow follows. A hidden built-in is invisible to search and load; each `search_skills` hit lists the `workflows` that follow it, Quick ones included, and the persona routes \"the finished deliverable\" to that workflow and quick or partial asks to the playbook inline; Tool calls made while proactive work is tagged (scheduler dispatch, reflection, research, alert review) feed the Attunement outcome ledger. Goals have three tools: `list_department_goals`, `update_department_goal` (progress the user reports) and `create_goal` (a new goal the principal states, filed under an area by slug or title and creating the area when none matches — see Org). `create_goal` runs only for the principal on a verified surface, and no unattended run is offered it: the tools in `schedule_tools.UNATTENDED_WITHHELD_TOOLS` are dropped from reflection's and research's toolkits by `unattended_toolkit`, and from the chat loop when its Session is marked `unattended` — the scheduler's PROACTIVE TRIGGER run, whose prompt quotes stored intent text; a call the model emits anyway gets an error tool_result. `record_decision_outcome` records how a past decision turned out — the weekly review lists decisions older than 30 days with no outcome as `[decision N]` — writing `decisions.outcome` (`episodic.update_decision`) and auditing the rationale; it has `create_goal`'s gate (the principal on a verified surface only; everyone else gets an audited refusal) and is in `UNATTENDED_WITHHELD_TOOLS` too. A missing or unknown id returns the decisions still waiting on an outcome, so the model can retry with the right one. Its schema is static; the solo persona has one line on it, and team mode learns it from the tool description, so the team persona is byte-identical. Attunement adds `list_open_loops` (what a person owes — everyone's for the principal only in a private conversation, otherwise the caller's own) and `close_open_loop`, which only closes a loop when the turn's resolved speaker is the principal or the loop's owner — an unrostered sender, a signed-in web user not on the roster, and unattended sessions are refused. `assign_open_loop` opens one on request (\"ask Ben to send me the Q3 numbers by Friday\") for the principal or a rostered teammate on a verified surface — never an email, a turn private to the principal or an unattended run, which are also withheld the tool — and only when `task` appears verbatim in what the speaker typed this turn, at most 5 per turn; that turn's extraction pass then opens no second loop for the same owner. The owner is told once on their own channel when someone else assigns it, and the result's `notified` says how that went, so the Executive reports it rather than messaging them as well. The roster write tools (`upsert_person`, `archive_person`, `set_department_head`) are the principal's alone, and only on a surface that verified the speaker — the web chat, the principal's own Slack or Discord, a channel whose adapter verified the sender itself (`Session.speaker_verified`, with the `caller_person_id` it resolved, never on a turn an email started), or a private Telegram chat when `TELEGRAM_WEBHOOK_SECRET` is set to a value Telegram can send (Telegram identifies the chat rather than the sender, and without that secret its webhook accepts anyone's update). A teammate, an email (even from the principal's address), Google Chat, the CLI, the MCP server and unattended runs get `status: refused` (audited, and no action chip), because a team member's People row is also the web sign-in and outbound-email allow-list. `upsert_person` takes `kind` (`team` / `contact`; a new person defaults to `contact` in solo mode, an update keeps the current kind) and `email_aliases` (other addresses the person writes from — the full list, replacing the current one; they match mail and may be emailed but never sign in, and one already on someone else is refused), and after every write any pending roster request whose sender now matches someone is closed and its held messages replayed. `resolve_roster_request` answers a **roster request** — someone not on the People list who wrote in and is waiting (see Org): `approve` (a new person; `kind` defaults to team for a company-domain sender, else contact, when the principal did not say), `link` (someone already listed, by `person_id`; `replace_channel_id` only when the principal said so) or `decline`. It has the roster tools' gate (the principal on a verified surface; audited refusal otherwise), is in `UNATTENDED_WITHHELD_TOOLS`, and answers only an id in `Session.trusted_roster_request_ids` — the ids the server printed that turn in a `` block (`briefing.context.format_roster_requests_for_prompt`, up to 10 pending requests), which `render_and_trust` appends to the `` digest on the principal's own verified turn only and records on the session in the same step, so an id the model invents or reads out of a message adds nobody. Each line is server-derived — id, channel, address or account id, the sender's name sanitised and marked unverified, a company-domain hint, the message count — and never what they wrote. The block rides in the **user turn**, like the digest, so the cached system blocks are unchanged; the tool definition is static, and the new tool only extends the sorted tool list. The web chat route resets the trusted set every turn, and a resolved id is removed from it. and `list_people` returns both kinds with `kind` — contacts only on the principal's own verified turn (they are private to the principal; anyone else gets the team as if no contact existed) — while `lookup_person` searches the team only. `ask_about_person` reads a person's peer memory fully only on their own verified turn; anyone else's answers only to a verified principal or teammate, and only when that person turned on \"Share my work style with the team\", framed to how they work and never their view of a colleague, and otherwise returns the same \"hasn't chosen to share\" result whether or not anything is known. A teammate's own mail with the Executive is theirs the same way: every mailbox call the model makes through the MCP gateway (Gmail or Outlook, by `call_tool`, from a script, or a workflow step run from chat) passes through `orchestrator.teammate_mail`. A search excludes every other teammate's address server-side, except on mail the reader is also on, and is filtered again entry by entry; any other call that names a message or thread (read, attachment, reply, forward, label) must use an id a filtered search returned to the same reader in this conversation (`Session.mail_ids_seen`) or the inbound email the turn answers; reads are filtered message by message, so a message naming another teammate is left out unless the reader is in its From / To / Cc; a reply that would quote such a thread, or an Outlook forward, reply or attachment read of such a message, is refused; and a conversation in which a teammate other than the principal was shown their own mail becomes theirs alone, as Act as me's do. Outside mail and the reader's own are untouched; an email turn reads as its sender only when Gmail authenticated it, and any other unverified reader sees no teammate's mail, a background run the model drives reads as the principal and a workflow step outside any session as nobody, an Outlook inbox rule that forwards or names another teammate is refused, and the poller and the brief, which read the mailbox themselves, are unaffected. Their own chats are theirs too: every audit row of a teammate's verified turn is theirs (`people_tools.teammate_turn_owner`, see Audit; the principal sees the actions, never the words), and a teammate's web chat or DM opens for them alone (`chat._session_access`), the principal included. The same verified-principal rule, as `people_tools.contacts_reachable_now()`, decides whether a turn may reach a **contact** at all: `message_person`, the raw DM tools, `create_calendar_event` / `create_instant_meeting` attendees and the MCP gateway's Gmail / Calendar / Drive / OneDrive gates treat a contact exactly like an unknown person on every other turn, so no refusal reveals that one exists. **The untrusted-content policy** (`orchestrator.content_trust`) is one rule for text the principal did not write — inbound email, chat, attached files, watched pages — answering three questions for every surface. *Who is speaking*: the principal only when the surface proved it (`principal_speaking`: the web chat as the principal — never a signed-in email on nobody's People entry —, the CLI, their own Slack/Discord or a secret-verified Telegram chat, or their own address with Gmail's `dmarc=pass`); never a teammate, a stranger, Google Chat, the MCP server or an unattended run. *What may become memory*: episodic extraction runs only then, and only on the words outside `` blocks; peer memory and open loops are recorded under the speaker's own name, never as the principal's. *What may trigger tools*: every write on the principal's authority already refuses others in its handler; the tools with no such check that change the install for every later turn — `content_trust.PRINCIPAL_ONLY_TOOLS`: `load_mcp_server`, `list_saved_tools`, `run_python_job` and `run_script` — are not offered unless the principal is speaking on an interactive surface (not email, even authenticated), and a call the model emits anyway gets an error tool_result and a `refused: not_principal` audit row. So an inbound email, a teammate's turn, Google Chat and the scheduler's proactive trigger each get a stable tool list of their own without it. A send or edit on a team member's turn also meets their department's authority first (`orchestrator.department_gate`, see Org), and so does one on a turn whose speaker is on no People entry: a signed-in web caller or a chat-channel sender, which in practice is Google Chat, since Slack, Telegram and Discord turn unmatched senders away. Google Chat names a matched sender to the gate alone (`gate_sender_person_id`), so the principal there is not held. Email turns are left to the mailbox's own gates. *How it is labelled*: outside text goes into the user turn inside `` with a fixed notice that it carries no authority; compatibility forms are folded (NFKC), control characters go, and the tag's name is renamed wherever the text carries it — any case, spacing or entity-escaped form — so the text cannot end its block and speak outside it.\n\n**Outside content** (`orchestrator.outside_content`). Once a turn has read text from outside the team (any gateway read, an attachment, a web search result, a synced Drive, OneDrive, Confluence or Notion excerpt, `read_document`, research, or an extension tool registered `reads_outside`), the rest of that turn refuses what the Act as me lockdown refuses, every `upsert_person` but a contact the speaker named in this message at an address they typed or one that sent the mail read (`lockdown.speaker_named_contact`; never on a turn answering mail from someone not in People, and a refused one points to an `add_contact` card), and every `call_tool` but a read: no mail, Docs, Sheets, Drive or calendar write. The company's own Drive is not outside: a Google read of one file, Doc, Sheet, deck or folder by id (`company_read_item`) is looked up first (`vet_reads`, once per id a turn), and when that item is owned by someone at the company, or kept on a shared drive only the company organizes, and shared with nobody outside it (`mcp_gateway._company_item`), the read marks the turn as a company read (`COMPANY`) instead. That still holds everything else, since anyone who can edit the file wrote its words, but when every read so far was of the company's own items, a rename or move (`update_drive_file` with only `file_id`, `name`, `add_parents`, `remove_parents`) goes through, so the turn can file what it read. Searches, mail, calendar and forms mark it as usual. A refused rename or move the person asked for can go on a `drive_files` approval card instead. A round that reads also holds that round's other such calls; the read itself runs. Scripts are checked call by call. The offered tools never change, so the cached prefix stays the same, and the hold ends with the turn. Mail from someone not in People (a contact or a stranger) counts as outside content from the start of its turn (`Session.sender_off_team`), so the whole turn is held, with no direct messages or watchlist removals either; it can still raise an alert, private to the principal, which is how that mail reaches them. Someone added to People in a turn (or given a new address or chat account) is not emailed, messaged, invited or shared with until the next one. What a turn held is remembered for the conversation's next turn (`note_held`, in process, for an hour). It is kept per conversation and speaker. When that speaker's next message is a go (a short yes such as \"go\" or \"yes, do it\", or one opening with go, go ahead, do it or proceed as its own clause: `GO_RE`), the first round that reads has its reads refused once (`defers_read`, with an error that names no tool and asks for nothing new), so the steps the person just approved run before a new read holds them again; from the next round reads run as usual. It runs only what the model calls in that turn, under every usual check. A shared chat, and a turn answering mail from someone not in People, remember nothing.\n\nHow much a read holds is the owner's setting, **When I read outside content** (`orchestrator.outside_mode`, one row in `outside_content_mode`, changed only by the principal through `GET`/`PUT /executive/outside-content` on Settings → Your Executive), fixed per turn in `begin_turn` (`TurnOutside.mode`). **Ask every time** (`ask`) is the hold above. **Only for outsiders** (`outsiders`, the default) skips what came from the team: a Gmail search that shows only ids and links, a Gmail message read where every message's sender is a teammate (not a contact) and Gmail authenticated it (`fact_confirmation.sender_authenticated` on a raw read of each id, at most 10) and no other address appears, and a calendar read naming only the team where the Executive itself organized every event (a teammate's invite counts as outside, since its organizer is whatever the mail that brought it says) (Google's `get_events` only when `detailed` or for one event, since the plain listing doesn't say who made each event; Microsoft 365's calendar reads) (`mark_read` / `from_the_team`, checked on the result; an `@` the address pattern can't read counts as a stranger). Attachments and synced folders (a file carries whoever wrote it, not whoever sent it), web pages, fetched documents, vendor apps, Outlook mail and every other gateway read still hold. **Don't ask** (`trust`) applies only on the principal's own turn on an interactive surface that isn't email or a shared chat (`content_trust.principal_only_withheld` empty): no read marks the turn, and reading their own mail holds nothing either (the Act as me lockdown and `app_control` are skipped via `trusted_turn`); anyone else's turn gets `outsiders`. Whatever the setting, a shared chat, another AI app's turn and mail from someone not in People are `ask`, sends still go only to people on People, and someone added this turn is reached no earlier than the next one. An unreadable setting is `ask`.\n\nA turn about the principal's private mail (`Session.private_to_principal`) reaches the principal only. It is neither offered nor allowed to run `schedule_tools.PRIVATE_TURN_WITHHELD_TOOLS` — everything that reaches someone else, publishes where the team reads, or starts work outside the turn: the broadcasts, meeting bookings and cancellations, `run_workflow` and research, follow-ups and workflow nudges, watches, artifacts, goal updates, saved workflows, skill drafts, and `load_mcp_server` (it connects to any URL, which can carry the turn's content). Through `search_tools` and `call_tool` it reaches only `schedule_tools.PRIVATE_TURN_MCP_TOOLS`: Google Workspace reads (mail, calendar, and Drive, Docs and Sheets search and file reads, with their Microsoft 365 twins; OneDrive's `download-bytes` only for a file's content) and the Gmail send and draft the gateway narrows to the principal. A search shows it nothing else, and a call to anything else (Google Chat, a Docs or Drive write, `manage_event`, another server) is refused (`schedule_tools.private_turn_withholds`). A call the model emits anyway, MCP tools included, gets an error tool_result and writes an audit row, and every audit row the turn writes is the principal's alone.\n\nA turn another AI app asks over the MCP server (`Session.from_mcp_client`, set by `ask_executive`) reads and advises but never acts, unless the request may act (below). It is neither offered nor allowed to run `schedule_tools.MCP_CLIENT_WITHHELD_TOOLS`: the private-turn and unattended sets, plus every direct message (`message_person`, the Slack, Discord and Telegram DMs), alert acks and new alerts, brief edits, closing open loops, roster and department-head writes, watchlist changes, workflow drafts and form proposals. It gets no Act as me, `recall_history` or extension tools, and of the gateway tools only `schedule_tools.MCP_CLIENT_MCP_TOOLS`: the private-turn reads without the Gmail and Outlook send and draft tools. The set is fixed, so the variant keeps a stable cached tool prefix. A call the model emits anyway, through `use_tool` or `call_tool` included, gets an error tool_result telling it the conversation comes from another AI app, so it can read and advise but not send, post, book or change anything, and writes an audit row (`refused: mcp_client`); `MCPGateway.call_tool` refuses the same calls again.\n\nA request may let that turn act (`Session.mcp_client_may_act`: the `x-mcp-client-actions: on` header on a request the shared secret authenticated, or `MCP_CLIENT_ACTIONS` where no secret is set). It then runs the speaker's own changes inside Open Executive (`schedule_tools.MCP_CLIENT_RUNS_WHEN_ACTING`: alerts, open loops, follow-ups, the watch list, goals, decision outcomes, facts and company profile writes; never the roster), each under its own owner-only and person checks, since the turn counts as a verified surface for its caller. It is still denied a fixed set, `MCP_CLIENT_ACTING_WITHHELD_TOOLS` (DMs, calendar writes, broadcasts, `load_mcp_server`, scripts, Python jobs, workflows, research and the rest of the read-only set), with a refusal telling the person to do it in Open Executive. A message or a meeting goes on an approval card instead (`propose_actions`), which is carried out at once when the person's Handle it for me covers it, everyone on it has been on the roster for a week, and `action_cards.on_its_own_refusal` finds nothing against it, and otherwise waits for their tap. Every audit row it writes names the app (`via`).\n\nA web chat its owner shared (`Session.shared_chat`) mixes people. The note naming who is in it says which of them owns it and that the others' lines are messages from them, not the owner's instructions. While someone other than the owner has written since the owner's last message (`Session.others_spoke_last`, set per turn by the chat route from the saved history, the speaker included, and true when the history can't be read), the turn is neither offered nor allowed to run `schedule_tools.SHARED_CHAT_WITHHELD_TOOLS`: direct messages, the broadcasts, meeting invites and cancellations, follow-ups, roster writes and workflows (a run, a save or a suggested schedule acts later with the full tool set). Through `call_tool` it can't send, reply, forward or draft mail in Gmail or Outlook, or make a calendar change that mails attendees (`schedule_tools.shared_chat_withholds`). That holds on the owner's own turn right after someone else's line too, so a request carried over from it can't send; the owner's next message with nobody else's in between lifts it. A call the model emits anyway gets an error tool_result and writes an audit row (`refused: shared_chat`). The principal-only tools stay out of every shared chat (`content_trust.principal_only_withheld`).\n\nOn every turn the Executive knows what is connected without searching: block 0's *Connected Systems* section names the connected Google services, other MCP servers, the messaging channels, Notion sync, Confluence sync (when on) and Act as me, and pins the Google tool names it calls directly with `call_tool`. Both the service list and the pinned names are narrowed to what workspace-mcp actually loaded (`WORKSPACE_MCP_TOOLS`, `WORKSPACE_MCP_TOOL_TIER`), so a service left out is not named and its tools are not pinned — being told a tool is already available when it is not in the index costs a failed call the model cannot explain to itself. `search_tools` is for anything not listed. When work repeats over many items, the Executive can also call `run_script` (`workflows/step_script.py`, offered when the gateway runs, unless `CHAT_SCRIPTS=false`, and only on the principal's own turns — `PRINCIPAL_ONLY_TOOLS`, withheld on an unattended pass too, because it spends the turn's whole call budget on code the model just wrote): one short Python script, run in a sandboxed Monty worker with no files, network or environment, whose only reach is the gateway tools `call_tool` could reach (those `search_tools` has returned; the gateway keeps that set for its own process-wide session, not per conversation). Each call it makes is a `call_tool` in every way that matters — the gateway's discovery, deny-list and recipient gates, Take the lead's gate on an unattended run (or the department gate on a team member's turn, see Org), and the same action chip and `tool_invocation` row (marked `via: run_script`, `ok: false` when it raised), as each call happens; the script itself gets a `kind: script` row with its source and result. Its wall clock is half the turn's deadline, so a long script ends with the list of calls it made rather than being cut off with the turn, and a stopped turn closes it and its worker. A turn private to the principal is not offered it, and the Act as me lockdown withholds it whole. Besides gateway tools, a chat script may call a fixed list of the Executive's own tools (`step_script.CHAT_OWN_TOOLS`: reads, alerts, open loops, goals, the watch list, follow-ups, `message_person`, `upsert_person` — nothing that broadcasts, books calendars, edits memory or skills, or starts workflows or research), each only when the turn is offered it and through the turn's own handler (Take the lead's gate included), with the same chip and a `kind: skill`, `via: run_script` audit row. A direct call needs a `tool_use` each, so a turn's scripts share a budget instead: `CHAT_SCRIPT_MAX_CALLS` (default 200) calls in all, and per-turn caps on own tools that could otherwise act in bulk (`CHAT_OWN_TOOL_CAPS`: `message_person` 10 — messaging the whole roster would be a broadcast — `upsert_person` 25, `create_alert` 50); a refused call tells the model to say what is left. A call stopped mid-flight by the script's clock or the turn still gets a `cancelled` audit row, since it may have run. Because every round re-reads the conversation, the loop also nudges at the moment of choice: after a round whose results include a list of five or more items (`step_script.lists_many`), with `run_script` on offer and not used that round, it adds the constant `FANOUT_HINT` as a text block in that round's user message (never a cached block), once a turn, pointing the per-item work at one built tool. For library work on files (PDFs, spreadsheets, charts, images) the chat also has `run_python_job` (see Workflows → Python jobs): Python with a fixed library set in a Deno-isolated WebAssembly sandbox, fed Knowledge files by name, the message's own attachments as sent, or text it already holds, and able to keep a job that worked as a Custom tool, with result files linked for the principal to download; it is offered only on the principal's own turns and only where the sandbox is installed (not offered otherwise, so the tool prefix stays fixed per process). Agent Activity shows each run as one \"Built tool\" card (debug event `script_run`: ok, the calls it made, how many, how long, and the kept or reused tool's name). The persona's tool-building section is present only when chat scripts can actually run (the setting and Monty installed). `TOOL_BUILDING_ADDENDUM` in the cached persona tells the Executive when to build a tool — a job that looks something up and then checks or acts on each result, paged results, more than about ten items, or an answer that needs three or more reads (find, open, read; the tool returns only what answers the question, not whole documents, and only what is about to be edited or quoted at length is read in full), while a handful of items already named go as direct calls in one turn — and never to say it has none, and to talk about it as building a tool or doing it in one go. On the principal's own verified, interactive turns, a script that works can be saved as a named tool (`save_as`; elsewhere the script runs but is not saved) and run again with `run_script(tool=…, inputs=…)`; `list_saved_tools` (a constant definition, in `PRINCIPAL_ONLY_TOOLS` with `load_mcp_server`, so offered only while the principal is speaking on a verified, interactive surface; `run_script(tool=…)` is refused on any other turn) lists them, and each call a saved tool makes is a gateway call like any other (see Workflows → Saved tools). The tool's definition is a constant, so the cached tool prefix does not move. Multiple parallel `tool_use` blocks in a single assistant turn fan out concurrently through `router.route_parallel`, each running its own domain-filtered ChromaDB query (3 built-in and 2 company chunks by default, see the RAG section) before the specialist sees the question. The fan-out is failure-isolated per call: a specialist that raises or returns no text becomes an `UNAVAILABLE` tool_result (`router.specialist_unavailable_result`) instead of failing the whole turn, and its area — \"finance\", \"legal\", never the specialist's name — is reported to the user under the web reply, or by the Executive in its reply on other channels, unless another call to it answered that turn. A failed specialist does not count as consulted, so it takes no committee seat. Cancellation (Stop) still propagates. The Executive then synthesizes the returned analyses into one reply.\n\n`remember_fact`, `forget_fact` and `update_company_profile` (`orchestrator/fact_tools.py`; the last two sit in the `memory` group of `open_tools`, since they are rarely needed) make a correction stick: the first two keep or retire a standing fact in `memory.facts`, and the third changes one field of the company profile. They share `record_decision_outcome`'s gate (the principal on a verified surface, an audited refusal for everyone else) and are in `UNATTENDED_WITHHELD_TOOLS`. `remember_fact` alone also admits a rostered teammate on a verified surface (web signed in, their own Slack or Discord): the fact is stored attributed (`recorded_by_role=\"teammate\"`, rendered `(per )`), and is held as a `proposed` row (`awaiting_approval`) until the principal approves it, unless the principal marked that teammate trusted (\"needs my approval\" off; on by default) — and even then when it would replace the principal's fact. All three are also in `PRIVATE_TURN_WITHHELD_TOOLS`, since what they write or retire is read on everyone's turns. On the principal's own authenticated email (primary address, with Gmail's own `Authentication-Results` reporting `dmarc=pass`, not forwarded) the three tools check the request as usual, then hold it as `awaiting_confirmation`. It applies only when the principal replies to a one-time token emailed to their own address (`integrations.fact_confirmation`); the split into a check step and an `apply_*` step lets chat and the confirmed reply share one apply path. Each write also needs a `source_quote` found in the speaker's own words this turn. The active facts reach every specialist as a `` user-turn tag: `route_to_specialist` reads them (None → the store, \"\" → none) for chat consults and workflow steps alike, and `BaseAgent.analyze` places the tag next to ``, never in the cached specialist system prompt.\n\nThe ten entries in `SPECIALIST_REGISTRY` (`openexecutive.orchestrator.router`):\n\n| Key | Agent | Domain |\n| --- | --- | --- |\n| `cso` | `StrategyAgent` | strategy |\n| `cfo` | `FinanceAgent` | finance |\n| `chro` | `HRAgent` | hr |\n| `gc` | `LegalAgent` | legal |\n| `coo` | `OperationsAgent` | operations |\n| `cmo` | `MarketingAgent` | marketing |\n| `cpo` | `ProductAgent` | product |\n| `sales` | `SalesAgent` | sales |\n| `board_comms` | `BoardCommsAgent` | board |\n| `triage` | `TriageAgent` | triage |\n\nModels: specialists default to `claude-sonnet-5-5`; the deep-reasoning tier (CSO, CFO, GC, Board) runs on `DEEP_REASONING_MODEL`, `claude-sonnet-5-5` by default, with adaptive thinking; routing/fast classification uses `claude-haiku-5-5`. Requests are fitted to the model's generation on the way out (`feature_gate.fit_claude_generation`): 5-generation Claude models never receive `temperature` / `top_p` / `top_k`, and Haiku 5.5, which thinks unless told otherwise, gets thinking turned off when the caller asked for none. The Council's **Deep reasoning** toggle is honoured on Claude Opus/Sonnet (direct or via OpenRouter) and on any OpenRouter model whose live catalog entry supports `reasoning` (today all OpenAI, Gemini, DeepSeek and Grok entries; not Llama) — the request translator turns Anthropic adaptive thinking + effort into OpenRouter's `reasoning` parameter. It is a silent no-op on models that can't reason, and disabled for Haiku, which rejects adaptive thinking. A PDF `document` block is translated too: it becomes an OpenAI `file` part, and OpenRouter requests carrying one add the `file-parser` plugin (`native` for models whose catalog entry lists `file` input, else `PDF_OPENROUTER_ENGINE`, default `mistral-ocr`). `FeatureSpec.supports_pdf_input` gates it per model — off for a local server unless `LOCAL_PDF_INPUT` — and a gated-off PDF becomes a text note rather than vanishing. The Council picks a model in two steps: a **Provider** dropdown (Anthropic, OpenAI, Google, Meta, DeepSeek, xAI, Local, …), then a **Model** dropdown limited to that provider, with readable names (\"Claude Opus 5.5\") and the raw id and serving route shown underneath. The Anthropic group combines the direct Claude models (`claude-fable-5-1`, `claude-opus-5-5`, `claude-opus-5`, `claude-sonnet-5-5`, `claude-sonnet-5`, `claude-haiku-5-5`, `claude-haiku-4-5`) with any Claude generation that exists only on OpenRouter, and never lists a model twice. With `OPENROUTER_ENABLED`, the non-Anthropic entries come from OpenRouter's live `/models` catalog (fetched at startup, refreshed in the background, filtered to tool-capable paid text models from an allowlisted vendor set), falling back to a built-in snapshot if the fetch fails. Every Council agent also takes **additional instructions** (`agent_overrides.instructions`): text appended after its prompt by `agents.overrides.append_instructions` inside an `` block. `BaseAgent.base_system_prompt()` is the built-in prompt or its full replacement (what the Council's editor shows); `effective_system_prompt()` is that plus the instructions (what every call sends), and the Executive gets the same text through `build_system_blocks(persona_instructions=…)`. Unlike a replaced prompt, instructions leave the agent on its built-in prompt, so prompt improvements keep reaching it. A **Quality** choice above the agents (`agents.presets`) sets every agent's model and deep reasoning at once: **Fast** puts every agent on a small model (`claude-haiku-5-5`, else `claude-haiku-4-5`) with deep reasoning off, **Balanced** returns each agent to its built-in defaults, and **Thorough** puts the Executive and the domain specialists on the largest allowed model (`claude-opus-5-5`, else `claude-opus-5`) with deep reasoning on where the model supports it, leaving triage and the helpers below on their defaults. Presets change quality only; they never cap spend. Each preset resolves against the install's model allowlist at request time and is offered only when one of its models is allowed. Applying one writes ordinary per-agent overrides (model and deep reasoning only), so history, rollback and later per-agent edits keep working; an agent changed on its own afterwards shows as **Custom**. Each Council agent has a **visibility**: `core` for the Executive and the domain specialists (`BaseAgent.visibility = \"core\"`, which a new specialist must declare) and `internal` (the default) for triage and the helper agents below. Thorough moves only core agents. The Council opens in a **simple view**: the Quality choice, the voice picker, and only the core agents, each with its additional instructions and a model badge. **Show all agents** lists the internal ones too, and **Advanced** opens the full page (the browser remembers the choice). Eight Council-only agents are model/prompt-overridable in the UI but NOT callable via `consult_specialist`, because they are deliberately kept out of `SPECIALIST_REGISTRY`: `quality_judge` (committee review), `utility_fast` (gates, titles, decision parsing), `research` (the `executive_research` fan-out), `fixture_generator` (invents demo companies), `engagement_intake` (models a real client from intake material), `onboarding_interviewer` (interviews a new user about their own company and drafts its profile, people and departments), `workflow_designer` (runs the conversational New workflow wizard on `/jobs/new`: searches the available tools, asks clarifying questions, then drafts a validated dynamic-workflow definition for the user to review), and `workflow_actor` (runs a workflow's `action` steps: a bounded tool-use loop limited to the tools the user approved for that step). `fixture_generator`, `engagement_intake` and `onboarding_interviewer` share one shape — a forced tool call producing a whole company, validated for referential integrity, with a single repair retry — but opposite epistemics: the fixture author invents, the other two extract and leave unstated facts empty rather than guessing. When the workspace is in solo mode, `onboarding_interviewer`'s first user turn is prefixed with a short hint (`interview.solo_hint(role_kind)`, one static constant per role kind from the onboarding role step): the draft names only the principal, as principal, and no departments; an owner is asked about the offer, customers, pricing, cash and runway, an in-house executive about their organisation (drafted as the company — their employer), function, remit, manager, team size as context and what they are measured on, an independent about their practice, clients and pricing, and with no role given the role-neutral `SOLO_HINT` asks which they are. The hint rides in the user turn and the tool array is unchanged, so the cached system prompt and tool prefix are the same in both modes.\n\n**Voice.** The Executive answers in one voice persona, substituted into block 0 at `{VOICE_PERSONA}` (`personas/loader.get_active_body`) from the Executive override's `voice_persona_slug`. Three general voices ship in `personas/builtin/`: **Direct** (`default`, the slug used when none is set: answer first, brief and decisive), **Supportive** (a warm coach who explains the why and checks how the plan sits) and **Analytical** (numbers first, options and trade-offs, then one recommendation). They are offered by a shared picker in setup (`/onboard`, after the workspace choice) and in Settings → Executive, which saves through `PATCH /agents/executive`; a built-in voice's frontmatter carries the picker's one-line `description` and `sample` reply. The named-executive voices are marked `legacy: true`: the picker hides them, but they still load, so an install that picked one keeps it, and the Agent Council lists them under \"Legacy voices\". Voice files hold disposition only; the response-length ladder lives in `prompts/executive_persona.py`, and a test keeps numeric length rules out of every voice file. An eval scenario may set `voice_persona` to answer in a voice; it rides on `Session.voice_persona_slug`, which wins over the override for that session only.\n\nOne more structured-output utility agent sits beside `triage`: `openexecutive.agents.alert_review.AlertReviewAgent` (routing model, one forced `emit_alert_reviews` tool call, never raises). The alert review job hands it a batch of open alerts with evidence and gets back, per alert, a verdict (relevant / changed / resolved / stale), a confidence, the evidence it relied on, a one-line note, why-now, an optional deadline, and a recommended move (none / route / nudge / escalate / draft / suggest_workflow / merge / close) with its parameters. Like `triage`, it never mutates state — `alerts.review.apply_verdict` executes the verdicts deterministically within authority, so the model's job is judgment, not action.\n\n**Solo mode.** When the workspace (or the turn's session) is solo — one person using Open Executive for themselves — the Executive works for that one person, the principal, whatever their role: they may run their own business, lead a function inside a larger organisation, or work independently, and the persona reads which from the context instead of assuming it — the role lines under \"Your Principal\" in the org block, when the principal gave their role (workspace settings: kind, title, reports to, remit, measured on). Its persona swaps the team sections (\"You Are a Member of This Executive Team\", \"Choosing Who to Tell\", \"Departments & Org Coordination\") for \"You Work for One Person\", \"The People in Their World\", \"Who Hears From You\" and \"Goals and Areas\". Solo means only the principal uses Open Executive — not that they have no people: their manager, direct reports, peers, clients, investors and board are contacts. The Executive helps the principal work with them (a 1:1 or review prep, an update or budget case for their manager or board, a negotiation) but does not coordinate them, contacts them only when the principal asks and only if they are a contact, and never offers to loop the principal in. Every specialist stays relevant — HR for the principal's own reports, board and investor communications for reporting upward. Goals are grouped by *area* (the tools' `department_slug` is the area's slug). The chat loop withholds `send_department_message`, `send_company_broadcast` and `set_department_head` from its tool list, and a call to one of them anyway gets a JSON error tool_result instead of running. The mode is resolved once per turn and pinned on the session, so the persona, the org block, the tools and every tool handler agree — a switch made mid-turn applies from the next turn. Team mode is unchanged.\n\nEvery specialist *advises* — each returns domain analysis the Executive synthesizes into one voice. None of them act on the company's behalf; the side-effecting tools in the main loop (schedule, people, departments, broadcast, watchlist, workflows) belong to the Executive, not to a specialist.\n\n`recall_history` (Always in the loop, `orchestrator.history_tools`) reads the speaker's own notes, on the same terms as `ghostwrite_email` below: its own registry, offered only on a verified turn in a conversation private to them with their switch on; its `due` flag lists only dated notes, soonest first (see Memory). The ghostwriter below also gets the writer's notes from mail every recipient was on, as a consistency check (``).\n\nThe morning reflection, which has no question to look up and no specialist to consult, gets one more read-only tool of its own: `search_knowledge` (`orchestrator.knowledge_tools`), the same knowledge lookup a chat turn makes, for what the company's documents say about a customer, contract, price or policy before it acts.\n\nA fifth, `remind_me` (`orchestrator.reminder_tools`), sets a plain-text reminder sent to the speaker alone when it is due; it opens no mailbox, so it never locks a turn. A sixth, `propose_actions` (`orchestrator.action_card_tools`), does nothing itself: it leaves the speaker a card of up to five actions (a meeting, a message, a new contact) that happen only when they tap Approve, so it stays on after a turn read mail; it is where what an email itself asks for goes, since that is never done on the email's say-so. Four read-only tools beside it read the speaker's own mailbox on the same terms — `search_my_email`, `read_my_email`, `read_my_email_attachment`, `my_email_awaiting_reply` (`orchestrator.mail_read_tools`) — and `my_calendar` their own calendar while Read my calendar is on, capped per turn, other people's messages and attached files returned as untrusted blocks (see Act as me). Beside them, `save_my_email_attachment` copies one attached file, when the speaker asked in their own words, into a fixed folder of the Executive's own Drive or OneDrive and shares it with them alone, every call built in codeOne tool writes under someone else's name, and it is fenced in code rather than the prompt. `ghostwrite_email` (**Act as me**, `orchestrator.delegation_tools`) writes an email as the person the Executive is speaking with and saves it as a draft in their own mailbox, Gmail or Outlook (`delegation.gmail.gmail_for` picks the client by the person's credential); chat never sends it (the person sends it themselves, or taps Send on its card). It lives in its own registry, never `_ALL_SKILL_TOOLS`, so reflection, research and workflow toolkits can never carry it. The chat loop adds it only on a turn `delegation.settings.pin_turn_delegation` offered it to — the principal, or a team member once the owner lets them, with it on, verified, in a conversation private to them (the web chat, a Slack or Discord DM, a verified private Telegram chat), on an attended turn — and builds that turn's handler map from both registries, so on any other turn a call to it is an unknown tool. From the round that calls it until the turn ends, the dispatch guard refuses only what opens an outside address, runs a script or workflow, or posts to everyone (`delegation.lockdown`), without changing the offered list; messages and invites to the roster, follow-ups, facts in the person's own words and a contact they name still run, each fenced in its own path. A later turn in the conversation stays private to its owner but holds nothing: the person asking again, having read the reply, is their own request. A contact add the lockdown refuses becomes an approval card in the chat instead (`propose_actions`). Recipients are chosen in code, never by the model (a draft's new email may go to any address, named back to check when it is neither on the roster nor typed), and the text comes from the **ghostwriter** (`delegation.ghostwriter`), a tool-less call with a constant prompt plus the person's \"How I write\" profile. The ghostwriter is deliberately not a Council agent: the Council's edit routes have no principal check. See **Act as Me (Delegation)**. A second tool joins the same way: `recall_history` (`orchestrator.history_tools`, Always in the loop) reads the speaker's own notes of what they said, by email or in chat. It has its own registry and per-turn handler map too, and the loop adds it only when `history_tools.recall_person` finds the speaker verified, in a conversation private to them, on an interactive turn that isn't private to the principal, with their notes switch on. It doesn't need Act as me. After every chat turn the Executive also hands the speaker's own words to `history_chat.schedule_chat_notes`, which takes notes in the background when their switch is on and the surface verified them (see **Memory**). A meeting `create_calendar_event` proposes works the same way when the person chatting is the one who approves it: its chip carries the `decision_id` and the chat shows the meeting card (Approve, Edit, Dismiss) under the reply, while the briefing keeps it too. A tool result held for someone else's approval (`waiting_for_approval`, the department gate) gets a *Waiting for approval* chip, never a done one.\n\nThe Executive can also launch *any* workflow conversationally: `orchestrator.workflow_run_tools` adds `list_workflows` (the launchable catalog with each workflow's inputs) and `run_workflow` (runs a built-in or custom workflow to completion and returns its artifact, checkpointing approval gates as `awaiting_human`). `run_workflow` runs the weekly review, and in solo mode the morning brief, only for the principal on a verified surface (`Workflow.principal_only_modes`, `create_goal`'s rule); anyone else gets an audited refusal that does not name the rule. A run whose `result` event reports `private_to_principal` (the morning brief on the principal's own verified turn, when it read their private rows) still returns its artifact into that turn, but the stored run keeps `persistence.PRIVATE_RUN_ARTIFACT` instead of the text, since run history is readable by everyone signed in. This makes the `/jobs` catalog reachable from chat — see the Workflows section.\n\nDeliverables are first-class too. `orchestrator.artifact_tools` gives the Executive three tools: `draft_artifact` publishes a memo, web page (`html`), Word document (`docx`), spreadsheet (`xlsx`, from structured `sheets`) or a `link` to something it created in one of the principal's connected apps through the generic MCP gateway (`search_tools` → `call_tool`, any server — no vendor is named), and returns the artifact's id and link; `list_artifacts` and `get_artifact` let it find and reread its own past work (drafts and workflow outputs alike, through the same `artifact_records` read model the API uses). Every artifact belongs to someone: a draft to the person whose turn published it (`artifact_records.current_viewer`; with nobody speaking, the scheduler or the CLI, the principal), and a run someone started by hand to them, while scheduled runs are the team's. The tools, the recall, `message_person`'s link and mail attachments only ever reach the speaker's own (a link is refused when the recipient couldn't open it), not even the principal sees a teammate's, and a speaker who isn't on the People list publishes nothing. A workflow's own session inside someone's turn (the research synthesis) is pinned to that turn's viewer (`Session.documents_viewer`). Revising is `draft_artifact(supersedes=)`, which archives the earlier version. When the principal asks for a deliverable the tool is always used; the \"quiet is the right default\" bar applies only to unprompted drafts. The format enum is a hard-coded sorted tuple so the cached tool schema stays byte-stable. Delivery: `message_person(person_id, text, artifact_id?)` appends the artifact's title and a `UI_BASE_URL/artifacts/` link (through the same outbound guard as any DM; the link needs Open Executive access), and a Gmail send/draft can attach the file itself by `artifact_id` — see Integrations.\n\n**Extensions** (`orchestrator.extensions`). An install can add chat tools and kinds of document without changing this code. `OPENEXECUTIVE_EXTENSIONS` names Python modules. The first turn, or the first Documents read, imports each one and calls its `register()`. A module that fails is logged and skipped. `register_tool` takes an `ExtraTool`: a definition, an async handler, an optional `tool_groups` group (new or existing) and an optional chip. It refuses a name the loop already knows (`executive.builtin_tool_names`). An extension tool is never in `_ALL_SKILL_TOOLS`, so reflection, research and the synthesis never see it. The chat loop adds it per turn, like the Act as me tools, but never on an unattended run, a turn private to the principal or to a speaker who isn't on the People list (mail from a stranger), and only where its own `offered(session)` says. That predicate depends only on the kind of turn, so each kind of turn keeps one cached tool list. Act as me's lockdown refuses it once the turn has read the owner's mail, because it is unclassified and the lockdown fails closed, unless it was registered with `after_mail_read=True`: a tool that only reads the install's own data and carries nothing the model wrote anywhere outside. Its chip goes through `action_chips.summarize_action`, which applies the same no-chip rules for an error or refusal and keeps a link to a path inside the app. `register_collection` adds a kind of document: a drafted artifact tagged `collection:` carries that collection on its record. The Documents page gives each collection its own filter. The collection's `on_change` hears about archive, restore and delete before they happen; if it raises, the change does not happen. `draft_artifact(supersedes=…)` refuses a collection's document. The alert pipeline strips `collection:` tags, so only the extension's own code files a document in its collection. A module that fails partway through `register()` leaves nothing registered. A tool registered with `workflow=True` is also offered to workflow action steps as `oe__` (see Workflows); `read_only=True` marks one that only reads. A step runs with nobody in the turn, so `offered` isn't asked: `extensions.in_workflow_step(name)` is True for that tool, during its call only, and the handler decides what an unattended call may do. `register_gateway_check` adds a check on every `call_tool` to one MCP server the install configures for the extension (tools `__*`; never `google_workspace`, `microsoft_365` or `fetch`, whose gates are core's, and one check per server): see Integrations.\n\nDocuments come in the other way through `read_document` (`orchestrator.document_tools`): it reads a PDF, Word, Excel, CSV, Markdown or text file by `path` — one another tool saved, such as a Gmail attachment (`get_gmail_attachment_content`) or a Drive file (`get_drive_file_download_url`) — or a company document by `filename`, with an optional PDF `pages` range. A scanned PDF is converted (`knowledge.pdf_reader`: local OCR by default; with `PDF_PROVIDER_READING=true`, the deployment's own model through its provider's PDF support, falling back to local OCR) rather than returned empty. Reads are confined to the tool download folders — the same resolve-then-contain check as `oe__read_file` — plus the company docs folder, and it is withheld on turns private to the principal (`PRIVATE_TURN_WITHHELD_TOOLS`): those come from a contact's email, whose own attachments the poller has already read into the turn, and must not steer the Executive into company documents.\n\nThe Executive also backs the **Ask OE** panel's form-fill tier via `orchestrator.form_tools.propose_form_values`: when a chat turn arrives with a `` block describing a form on the user's screen, the Executive can propose values for it. The proposal is delivered to the UI as a `form_patch` SSE event and placed into the form as highlighted suggestions — the tool never saves or submits anything; the user reviews and saves manually. The tool is **always** in the tool list (tool definitions sit at the top of the cached prompt prefix, so a conditionally-present tool would invalidate the prompt cache whenever panel and non-panel turns alternate); its description restricts usage to turns that carry a form descriptor. For questions about the product itself, the always-offered `read_user_guide` tool (`orchestrator/guide_tools.py`) returns any `guide/prebuilt/.json` section; its description lists the section ids, fixed at import, so it never changes the cached tools prefix.\n\n**Take the lead** (`orchestrator.take_the_lead`) is how the Executive acts on its own, behind one gate. It has two switches, both off until someone turns them on. **As the Executive** is the principal's alone (`PUT /take-the-lead`). With it on, the unattended passes (reflection, research, the scheduler's proactive trigger) keep the acting tools solo mode otherwise withholds from them: booking, starting workflows, and messaging teammates rather than only the principal. `schedule_tools.unattended_toolkit` and the chat loop's unattended path then wrap every acting tool (`ACTING_TOOLS`, plus any MCP tool whose name sends, creates, updates, deletes or shares) in `gated_handlers` / `gated_call_tool`. The principal-only tools (`UNATTENDED_WITHHELD_TOOLS`) stay withheld, and every guard inside a handler still applies, including department approval levels (a booking in a department on Proposes is still a proposal) and the contact-egress rule. New mail or chat (any turn with an `origin_channel`) wakes the existing reflection early (`take_the_lead.wake`): one run waiting at most, five minutes after the first message and no more than every 30 minutes, as a one-off `take_the_lead_wake` scheduled action. Pause holds it like any scheduled work. **As you** is the Handle it for me dial's top step (delegation section).\n\n**The gate** is plain code (`take_the_lead.check`). Six kinds of action wait for a yes first, each with an Ask first switch, all on to start. The switch is the decision ledger's own mode for the class `take_the_lead_action:` (`propose` asks first, the default; `auto_execute` doesn't). The kinds are money, contracts and legal, hiring and people decisions, deleting/cancelling/sharing, someone new (an address on neither the roster nor the contacts) and big sends (a broadcast or department message, or more than five people). On top of these, the company (set by the principal) and each person (for their own As you) add structured rules: a person or address, a domain, words, or an amount at or above a figure. Added rules always hold, and an error reading them holds the action. The one thing the gate lets straight through is a direct message (`message_person` or a Slack, Telegram or Discord DM) whose only recipient is the principal (`_only_to_principal`): they are who it would wait for, so a card would only ask them to approve a message to themselves. Anything that reaches or acts on anyone else is checked, and a roster that can't be read is not the principal. A held action becomes a `take_the_lead_action` decision instance with a companion `DECISION_ALERT_SOURCE` card on Today, routed to the first teammate holding one of its People approval ranges (`take_the_lead.approval_ranges`, plain code, closest fit first: money to `spend_lt_2k`, `spend_lt_10k` or `spend_gt_10k` by its largest amount, with a higher range able to approve a lower one and no amount read as large, or to `customer_credit` for credit and payment terms; vendor contracts to `vendor_onboarding` then `legal_sign`, other contracts to `legal_sign`; hiring to `hiring_signoff`; anything else about the board or investors to `board_comms`; in Personal as in team mode), or else the principal through the authority gate's fallback (`departments.authority._route_proposal`). Deletes and shares always go to the principal. Only the principal and that approver see or resolve it (`DecisionClassSpec.principal_and_approver`). Approving it claims the row and carries out the exact call once (`take_the_lead.carry_out`: the unwrapped handler, or the gateway's `call_tool`, on an unattended session, with contact egress only when the principal approves). Declining drops it. Everything done or held is a row in `take_the_lead_log`. What it did and what was approved show in Recent activity (`today._build_activity`, kind `took_the_lead`), named but never quoted, since that feed is the team's. The held card's own `decision_resolved` row is left out because its payload quotes the message. `GET /today/activity` also adds the caller's own Handle it sends (kind `sent_as_you`, actor `you`), which are never in the shared feed.\n\n**In training.** The As the Executive switch has three positions: Off, **In training** and On (`take_the_lead.training`). In training, an action the gate would let through still waits, as a `training` hold for the principal alone, unless the principal has allowed that action with that person or thing (`take_the_lead.allowance`: \"Message Priya Nair\" covers a message to her on any channel; \"Book meetings with Priya Nair and Sam Lee\" meetings with exactly them; a workflow by id; a connected tool by name and the file it names, never one that reaches people such as a mail send or a share). The card carries Approve with a **Do this on its own next time** tick (`allow`), **Edit** (the message, a meeting's title, times or description, a task and its due date: `take_the_lead.apply_edits`) and Decline. Allowing is the principal's alone and needs a request the API can tie to them, as turning it on does; the six kinds and the added rules hold whatever is allowed. An edit approved with \"Do it like this next time\" is kept as an example (only the edited text, the principal's own words), and `take_the_lead.learned_note` hands the examples and the fact it's in training to the reflection and research passes in their user turn. After `SUGGEST_AFTER` (3) unchanged approvals of the same action in 30 days, Settings suggests allowing it. What it's learned is one list keyed by feature (`take_the_lead_allowed.feature`), so other features that learn from approvals can join it; each keeps its own switch. Act as me is the second (`act_as_me`, per person, `delegation.training`), with a training switch per setting (Replies, Follow-ups, Suggested actions, Drafts): its rows carry the person's id and the setting in their key, are listed and removed by that person alone, count toward their own cap, and never reach `learned_note`.\n\n**AI disclosure.** Nothing the Executive sends to people outside the web chat goes without saying it is an AI: plain code adds the line in the MCP gateway (mail) and at each chat send (Slack, Telegram, Discord, Google Chat replies, its DMs and room posts), never the prompt, so the persona and the cached system blocks are unchanged and a reply the model writes can't drop it. Replies the inbox watcher writes as a person carry their own line by that person's switches. See Integrations → **AI disclosure** and Act as me → **AI line on replies**.\n\n**App control from chat** (`orchestrator/app_control.py`, catalog in `orchestrator/app_control_catalog.py`). Three tools in the `app` tool group let the Executive do what the app's buttons do when the person asks (\"pin this\", \"delete that chat\", \"turn off Handle it for me\"): `find_in_app(kind, query)` lists what the speaker may change (their chats, topics, documents, workflow runs, settings; files, memory, departments, skills and workflows for the principal), `do_in_app(action, id, value)` does one action (`id: \"this\"` is the chat they are in), and `open_in_app(screen)` returns an in-app link. Each action calls the API route its button calls, as a function, with a request naming the turn's verified speaker (`correction_tools._speaker`; an email or unattended turn gets a refusal), so the route's own checks decide what this person may do. Actions whose route checks nothing about the caller (their page is the principal's) are `principal_only`. Deletes, shares, turning on Act as me, Handle it for me or Take the lead, removing a Take the lead rule, clearing every card and approving a workflow's waiting step never run in the turn: they become an `app_action` decision card, private to the speaker, shown under the reply; the tap runs the action with the tapper's own request through `POST /decisions/{id}/approve` (claimed once; a card older than a day is turned down). After a turn reads outside text or the speaker's mail, every action becomes a card. The tools are withheld from unattended and AI-app turns, and `do_in_app` from a shared chat someone else spoke in. Snoozing a card uses `ack_alert`'s trust rule: only ids the server showed or found this turn. Extensions add kinds, actions and screens with `register_kind` / `register_action` / `register_screen` at startup. The person's notes stay with `recall_history`; account, sign-in and billing stay on their screens.", "mermaid": "flowchart TD\n User([User message]) --> Exec[\"Executive
orchestrator\"]\n Exec -->|consult_specialist| Cso[\"cso: StrategyAgent\"]\n Exec -->|consult_specialist| Cfo[\"cfo: FinanceAgent\"]\n Exec -->|consult_specialist| Gc[\"gc: LegalAgent\"]\n Exec -->|consult_specialist| More[\"chro / coo / cmo / cpo / sales
board_comms / triage\"]\n Mode{{\"Team or solo
(persona + tools per turn)\"}} --> Exec\n Exec -->|\"ghostwrite_email (Act as me)\"| Gw[\"ghostwriter
(tool-less, not a Council agent)\"]\n Cso --> Claude[\"Claude (Anthropic)\"]\n Cfo --> Claude\n Gc --> Claude\n More --> Claude\n Claude --> Exec\n class User entry\n class Exec compute\n class Mode compute\n class Cso compute\n class Cfo compute\n class Gc compute\n class More compute\n class Claude external\n class Gw compute", - "generated_at": "2026-10-19T21:00:11Z" + "generated_at": "2026-10-20T08:04:35Z" } diff --git a/packages/core/openexecutive/architecture/prebuilt/integrations.json b/packages/core/openexecutive/architecture/prebuilt/integrations.json index 9f2730268..705a8205c 100644 --- a/packages/core/openexecutive/architecture/prebuilt/integrations.json +++ b/packages/core/openexecutive/architecture/prebuilt/integrations.json @@ -1,7 +1,7 @@ { "section_id": "integrations", "title": "Integrations", - "markdown": "External channels connect callers to the Executive. Each inbound adapter resolves the sender to a Person — the chat adapters via `find_person_by_{slack,discord,telegram}_id`, the email poller via `people.identity.resolve_email_sender` (a primary address or an alias, then the company-domain rule; see Org) — and threads `person_id` into `Executive.chat()`. A sender who resolves to nobody is no longer simply dropped: see **New senders (roster requests)** below.\n\n### Inbound channels\n\n- **Slack** (`integrations.slack_bot`) — async Bolt app (`AsyncApp`) embedded in the lifespan, hearing Slack one of two ways: socket mode (`AsyncSocketModeHandler`, with `SLACK_APP_TOKEN`) or, with only `SLACK_SIGNING_SECRET`, the Events API over HTTP at `POST /webhook/slack` (`integrations.slack_events`), where the route checks each request's signature and age (and a 1 MB cap) before Bolt sees it, so unsigned traffic never reaches Bolt's logging; it then takes each signed request once, answering an identical replay (same signature) or Slack's re-signed retry of an `event_id` it already took with an empty 200 that never reaches Bolt (an in-memory, per-process set kept for twice the signature window and capped in size; keys are forgotten if Bolt fails, so a retry of a failed delivery still runs), and Bolt acknowledges at once while the listener runs. Either way a repeated event id is dropped (Slack resends what it thinks went unacknowledged), as is an event delivered for another workspace. Responds to `@mention`s, DMs, and thread continuations, with per-conversation session memory (see **Conversation state** below). Text only: there is no interactivity endpoint and no Block Kit, so nothing Slack sends carries a button.\n- **Discord** (`integrations.discord_bot`) — discord.py gateway embedded in the lifespan; per-thread auto-titling, session-scoped memory, author attribution, configurable `@mention` thread promotion.\n- **Telegram** — webhook gating + per-chat history. Replies (and every outbound Telegram message) go out as Telegram's HTML subset (`utils.markdown_telegram`: markdown-it-py with raw HTML and images off, text escaped), so the Executive's Markdown shows as formatting; links show as their text and target in plain text and never become tags, so quoted text can't hide where one goes; a chunk Telegram refuses is sent again as plain text.\n- **Google Chat** — webhook + service-account auth. It resolves no approval gates and has no roster gate (the webhook authenticates Google, not the sender), and it never populates the session's trusted alert ids, so `ack_alert`'s trust check — which now runs on every session rather than only those declaring an `origin_channel` — refuses there by default, and its `` block says plainly that a sign-off cannot be answered from it. It does look its sender up on People by the Google account email on the signed event, but hands that entry only to the department gate (`Session.gate_sender_person_id`): the principal's own sends and edits go, a teammate's meet their department's authority, and anyone else's wait for approval. It is never `caller_person_id`, so it grants nothing else.\n- **Email** (`integrations.email_poller`) — MCP poll loop over the Executive's own mailbox — Gmail via `workspace-mcp` or Outlook via `ms-365-mcp-server`, chosen by `EMAIL_PROVIDER` (see *Workspace providers* below); it starts only once the MCP gateway is up, never surfaces Reply-To redirects, filters auto-replies. Its sessions carry `origin_channel=\"email\"`: the untrusted-content policy (`orchestrator.content_trust`) reads that tag to tell mail from the principal's own surfaces — left empty, as it once was, every stranger's body reached the decision extractor as the principal's words. The mail goes into the turn inside an `\" author_verified=\"no\">` block that says it carries none of the principal's authority, with the framing line and any `[POLICY]` notice outside it; mail from the principal's primary address that Gmail authenticated (`dmarc=pass`) is their own words — but only their new text goes in unwrapped: when the mail quotes or forwards another message (`_new_text_lines` cut something), the message as received follows in an `` block, since a stranger's text they replied to or passed on is not theirs. While the Executive is paused (`scheduler.pause`) it skips each poll and leaves the inbox unread, so held mail is processed on the first poll after resume; the chat adapters above are not paused. The poller handles one message at a time, so each Executive turn on an email has a deadline, `EMAIL_TURN_TIMEOUT_S` (300 s): past it the turn is cut off, an `integration_inbound` row with outcome `turn_timeout` records it, and the message is marked read and not run again (it may already have replied). A message whose handling keeps raising is given up after three tries in a run and marked read, rather than retried every poll. Each gateway call it makes is bounded by `MCP_TOOL_TIMEOUT_S` (see Lifecycle). The Executive sees the whole message; peer memory records only `_email_memory_text` — the subject, the sender's new text (cut at the first quoted or forwarded message) and the attachment filenames — parsed from the rendered message with any trailing `--- REPLY ---` block dropped first, without backtracking-prone regexes, since the text is sender-controlled, and only for a rostered sender, the only kind with a peer to record into. The poller's `integration_inbound` row keeps the mail's text for the audit log, the way the reply's `chat_turn` row keeps the Executive's answer: `full.message` is the sender's new text (what web chat keeps for a turn), `full.attachments` the attachment filenames, and `details.preview` the first 120 characters of the new text, so /audit shows what was asked next to what was answered (the summary still reads `Inbound email from : `). The whole body with its quoted chain (`full.body`) is kept only on a row private to the principal: the chain is other people's mail — a contact's, on the principal's reply to one — and every signed-in user reads a public row. Each field is cut by its escaped JSON length (20,000 characters of text, 8,000 of attachment names), the measure the logger's 64 KB cap uses, so a long mail in a non-Latin script loses its tail rather than the row's structure. Outbound, the principal briefs and the client-rotation digest are emailed through the same gateway with the configured backend's send tool (`google_workspace__send_gmail_message` or `microsoft_365__send-mail`, via `workspace.registry.send_from_executive`, from the Executive's own mailbox to the principal's address, subject ` — `, the date in the user's zone) as formatted HTML — rendered from the brief's Markdown with raw HTML and images off, so text quoted from outside shows as text and opening the email loads nothing remote. Email is first when the principal prefers it, and otherwise the backup after every chat channel, tried only when Slack, Discord and Telegram are missing or fail; an in-band error from the tool counts as a failed send and the next channel is tried. An emailed brief is not a DM, so the per-Person outbound guard below does not apply — the gateway's roster egress gate does.\n\n Two senders get their own framing. Mail from one of the principal's **contacts** (a `kind=\"contact\"` People row — see Org) replaces the non-roster `[POLICY]` notice with a contact one: it names them (name and role/company), says not to reply unless the principal asks and not to tell anyone else, and asks for a summary in an alert for the principal instead. Mail from anyone not in People, a contact or a stranger, is held as outside content for its whole turn (`orchestrator.outside_content`): no connected-app write, contact change, follow-up, link, script or workflow, no direct message, only reads and an alert private to the principal. Both this turn and the forwarded-by-principal turn below are marked `Session.private_to_principal`: they can email, DM or invite only the principal, an alert they raise is private to the principal, and they are not offered the tools that reach anyone else or start work outside the turn (`schedule_tools.PRIVATE_TURN_WITHHELD_TOOLS`: broadcasts, meeting bookings, workflows, follow-ups, watches, artifacts, goal updates, skill drafts, connecting a new MCP server), and of the MCP tools only Google Workspace reads and the gated Gmail send (see MCP below). Every audit row about such mail is private to the principal (see Audit Log), the poller's two `integration_inbound` rows included (`_private_to_principal_mail`), so the audit log's other readers see none of it. The poller holds `audit.private_rows` for the whole handling of such a mail, because some rows are written before the turn binds its private session: the knowledge retrieval quotes the mail's From line, subject and opening. The rows themselves read exactly like an outside sender's: the `integration_inbound` row is the non-roster one (`accepted_non_roster`), and the notice opens with the non-roster notice's exact sentence. The poller handles each message with no session bound, so one message's private turn never marks the next message's rows. The turn stays unattributed (`person_id` None — no peer memory, no co-presence), and the gateway refuses mail to a contact on a poller turn anyway. Mail **forwarded by the principal** — the sender resolves to the principal and `_new_text_lines` reports a forwarded message — is wrapped in a `` block: summarise it, draft a reply the principal could send (as text in the reply, never sent or saved as a Gmail draft), note any commitment or date, and offer to add the original sender as a contact (confirmed from the web app or the principal's Slack / Discord, since an ordinary email turn cannot change the People list — the one email that can is the principal's tokened reply to a roster-request email, which is parsed before any model turn; see below). The reply still goes to the principal only. The From header is unauthenticated, so this framing grants nothing: a spoofed \"forward\" just gets the real principal a summary.\n\n **An email that got no reply goes to Needs you.** Mail from the team or the principal is answered by the model calling the mailbox's send tool, so a turn can end with nothing sent (a tool refused, the answer only written). The gateway marks on the conversation who each accepted send reached (`Session.mail_sent_to`: Gmail `send_gmail_message` to/cc, Outlook `send-mail` recipients; `mail_replied_ids`: the message an Outlook `reply-mail-message` / `reply-all-mail-message` answered). After the turn the poller (`_note_reply_outcome`) checks the sender, by any of their addresses, or the message id; with no reply it files a card through `integrations.unanswered_mail` (source `unanswered_email`, high, tagged `private:principal` and routed to the principal, so only their own /today shows it), whatever the model did. Repeats in the thread fold into it (one card per thread, occurrence count), and a later turn that does reply resolves it. A reply the poller sees is also logged (`unanswered_mail.record_reply`, a private `integration_outbound` row keyed by thread), and the briefs read both: each INBOUND email from the roster says \"the Executive replied\" only on that evidence, or \"no reply went out\" while its card is open (`open_thread_keys`), so the brief stops telling the principal to answer mail the Executive already answered. Only mail Gmail proved is the principal's (`Session.email_authenticated`) reads \"Your email got no reply\" with its subject and what the Executive wrote, which never reached them; a teammate's card names who is waiting and never their subject or text (their conversation is theirs alone); mail that only claims a teammate's or the principal's address says it could not be proven and carries nothing the sender wrote. A plain-text `Error …` tool result is not counted as a send. Contacts and senders off the team are not answered by design and keep their own paths. Why: the brief read the inbound log and said people were waiting while Needs you, which shows alerts only, stayed empty, since an alert depended on the model choosing `create_alert`.\n\n **Other mail sources** (`integrations.mail_sources`). Mail read from anywhere but the Executive's own mailbox (a hosted inbound address, a relay) follows the same rules through a vendor-neutral hook. Nothing is registered by default and the hook has no settings: a deployment registers a `MailSource` (its loop, an `enabled` check, and how to replay a held message), the lifespan starts the enabled ones, and each message goes to `handle_inbound` as an `InboundMail`: the parsed message, the address it came to, `prove_sender` (DMARC, or SPF and DKIM aligned, as the source can tell), `send_answer`, an optional attachment reader and what a held message keeps. `handle_inbound` runs the poller's own handling (`email_poller._handle_mail`): sender resolution, private-to-principal rows, the teammate's own rows only when proven, untrusted-content wrapping, the principal's proven mail, the forwarded-by-principal framing, hydration and the turn deadline. Because a source mails the answer itself (with the AI line, and an `integration_outbound` row) rather than the model calling a send tool, three things differ: a stranger is held for the principal silently, with no acknowledgement and no turn, and replayed through the source that read it (`replay_held_mail` dispatches on the payload's `mail_source`); a contact's mail, and mail claiming a teammate that nothing proves, is answered to the principal and never to the sender; and roster-request and fact confirmations are answered only from the mailbox they were sent from. A source must be registered to be handled, it does nothing while the Executive is paused (`handle_inbound` returns `PAUSED` and the source keeps the message), a stranger the request queue won't take is logged as not held, and a sender who is neither team nor contact by the time the turn runs gets no answer at all.\n\nSlack, Discord, and Telegram are roster-gated on inbound: an unrostered sender's message never reaches the Executive (`details.outcome=\"rejected_unknown_sender\"`). The gates look up **team members only** — a contact's Slack, Discord or Telegram id is treated as unknown, so a contact can never talk to the bot (nor count as a co-present peer). What happens next is the roster request below — except for a contact, a thread continuation, a Telegram group or an unverified Telegram webhook, which stay silent. Email is not inbound-gated — non-roster mail flows through with a `[POLICY]` notice so triage can classify it. Slack/Discord thread continuations pass the shared `integrations.response_gate` (utility-fast model, biased toward replying). Slack only continues a thread it has replied in: it reads the thread with `conversations_replies` (5 s bound) and looks for its own user id, which `auth_test` resolves at startup. If that lookup fails, mentions and DMs still work and thread continuations stop until a retry succeeds (`slack_bot._resolve_bot_user_id`, at most once a minute, from the channel `message` listener). When Slack doesn't return the thread in time, the thread's stored history (`slack:thread:{channel}:{thread_ts}`, written on every turn the bot answers there) stands in: with history, a rostered sender gets an ephemeral \"I couldn't read this thread just now… @-mention me\" and the drop is audited (`outcome=dropped_thread_unreadable`). A thread the bot never answered in stays silent. A message the gate skips is audited (`outcome=skipped_gate`, with the gate's reason) and gets an :eyes: reaction (`reactions_add`, which needs the bot's `reactions:write` scope; without it the reaction is skipped), so a sender the gate misjudged can see the message was read and @-mention the bot.\n\n**No link previews on replies.** The Executive's replies go out with previews off — Telegram `sendMessage` carries `link_preview_options.is_disabled`, Slack's reply `say` passes `unfurl_links=False` / `unfurl_media=False`, and every Discord reply send passes `suppress_embeds=True`. The direct messages the Executive starts are the same: `send_slack_dm` posts with `unfurl_links=False` / `unfurl_media=False`, and Discord's `send_dm` sets the `SUPPRESS_EMBEDS` flag on each chunk, so a reminder or a brief sent by DM carries no preview either. Otherwise the chat service fetches a link in the text on its own, so a reply that quotes someone's mail (Act as me reads it) could carry data to a sender's server with no click. The web chat does the same for images: `Message.tsx` loads only an image the app itself serves (`lib/markdownImages.loadsInline`) and shows any other as a link.\n\n### Alert triage of chat messages\n\nEvery chat-app message that passes the roster gate (Telegram, Slack, Discord, Google Chat) is also handed to alert triage through `alerts.pipeline.schedule_chat_message_evaluation`. The sender is named by their People record (`full_name`), not by the name they typed into the chat app, so a person whose account there is called something else is never read as a stranger; the chat app's own name (or raw user id) is used only when nobody on the roster matched. The principal's own messages to their Executive are a conversation, not news for their brief, so they are not triaged at all. Triage sees the sender as `sender:` and the room as `channel:`. Google Chat's inbound audit row also carries the matched People id (`sender_person_id`), looked up before the row is written, so the brief can tell the principal's own messages apart by id, never by a display name anyone can set.\n\n### New senders (roster requests)\n\nSomeone who writes in and is not on the People list — and is not one of the principal's contacts — is **held, acknowledged and asked about** instead of dropped (`integrations.roster_intake.intake` over the `people.roster_requests` ledger; lifecycle in Org). Each adapter calls it after its gate:\n\n- **Slack** — a DM or an `@mention` (not a thread continuation: the bot was not addressed there). The name and profile email come from `users_info` (5 s timeout). The acknowledgement goes in the DM, or as an **ephemeral** message for a mention, so nobody else in the channel learns who is and is not on the roster. The held event keeps only a fixed set of keys (text, user, channel, ts, thread, files).\n- **Discord** — a DM or a mention (not a thread the bot merely follows). The acknowledgement goes in the DM, or by DM for a channel mention; `/ask` answers the interaction with an ephemeral acknowledgement first (Discord allows 3 s), then holds the prompt. Its old \"ask the admin to add you\" reply is gone.\n- **Telegram** — only a **private** chat, and only when `TELEGRAM_WEBHOOK_SECRET` is set and valid: Telegram identifies the chat rather than the sender, and without the secret anyone could forge an update. A group, or an unverified webhook, stays silent as before. Attachment file ids are held and downloaded only on replay.\n- **Email** — non-roster mail that is not private to the principal (so never a contact's) and does not look machine-sent (`looks_automated`: a no-reply / notifications / billing-style local part, a `List-Id` / `List-Unsubscribe` / `Auto-Submitted` / bulk `Precedence` header, or a DMARC failure). The mail is **still triaged** by the Executive on this pass; its `[POLICY]` notice now says the sender was already told their message is waiting and the principal was asked who they are, so the model raises no second alert just to add them, and that the email will come back once they are added.\n\nEvery path skips a contact first (`find_person_by_*_id(..., include_contacts=True)`, or the email poller's private-mail rule): a contact has no chat access and gets no request and no acknowledgement, and the team-visible `rejected_unknown_sender` / `accepted_non_roster` rows read the same for a contact as for a stranger.\n\n**The acknowledgement** is one fixed text (`roster_intake.ACK_TEXT` — \"your message arrived … waiting for confirmation\"), never the model and never an echo of what they wrote, so a forged sender gains nothing by triggering it. It is sent at most once per sender per `ROSTER_ACK_WINDOW_DAYS` (7) and at most `ROSTER_ACK_DAILY_CAP` (20) a day across all senders (`claim_ack`, one transaction; a failed send releases its claim). An email acknowledgement is the one send the MCP gateway lets reach an address off the roster: `send_email_ack` sets `mcp_gateway.roster_ack_grant`, a one-shot ContextVar grant that admits a single `send_gmail_message` with exactly the granted recipient (a plain string, no list), subject and body and no other key, then is spent (audited `integration_outbound`, `kind: roster_ack`, private). An email sender is acknowledged only when Gmail authenticated them: once `claim_ack` succeeds, the poller's `send_ack` re-reads the mail as raw MIME and needs Gmail's own topmost `Authentication-Results` reporting `dmarc=pass` for the From domain (`fact_confirmation.sender_authenticated`, fail-closed). The printed headers the poller reads never carry that header, so without the second read a forged From would draw the acknowledgement to whoever it names — backscatter from the Executive's mailbox. An unauthenticated sender makes `send_ack` raise `roster_intake.AckWithheld`: the claim is released, and they are held silently. `authenticated_by_gmail` strips quoted strings and comments before reading the verdict — Gmail copies the envelope sender and DKIM tags into its own stamp, and a quoted local part could otherwise carry a fake `dmarc=pass` — and needs exactly one `dmarc=` verdict reading exactly `dmarc=pass header.from=` (Gmail may follow it with its own `dara=` resinfo, so its position is not checked). Residual: when Gmail writes no verdict at all (a From domain without DMARC) and echoes an unsanitised `;dmarc=pass header.from=…` elsewhere in its stamp, that echo would read as the verdict; bounded by the acknowledgement caps. A withheld acknowledgement is audited `roster_ack_withheld`. `intake` acknowledges before surfacing the card and telling the principal, and both follow the request's `ack_sent_at`: a withheld, failed or capped acknowledgement reads \"haven't been told anything yet\" on the Today card, in the principal's prompt and in the email `[POLICY]` notice.\n\n**Telling the principal.** A new request puts a private card on the principal's `/today` (see Today) and pushes a prompt on their delivery plan — preferred chat DM, then the others, then email — for a chat sender or an email sender on the company's own domain; an outside email sender waits on the card, as does everything past `ROSTER_REQUEST_DAILY_CAP` (30) pushes a day (the same cap bounds how many requests open a day; a declined sender is not raised again for 30 days). A chat prompt can be answered in place (\"that's Annamarie, add her to the team\"). An email prompt carries a one-time token (`RR-` + 20 base32 characters, stored hashed); the poller checks every inbound mail for one **before** triage (`try_email_roster_answer`): the reply must come from the principal's primary address exactly (not an alias, not the company-domain rule), carry the token of a pending request, not be an automatic reply, and not fail DMARC where the header is visible; and is parsed by `parse_decision`'s `roster_identity` shape — anything else falls through to the ordinary path. The token is hidden (`RR-[hidden]`) in every other Google Workspace result the gateway returns — the confirmation sits in the Executive's Sent folder, and any turn could otherwise read it — except the poller's own fetch of an inbound message (`mcp_gateway.reveal_roster_tokens`); a token-bearing mail that is not an answer is scrubbed before triage.\n\n**Replay.** Once the principal answers (web card, chat tool, email reply) — or a People-page / `upsert_person` write makes the sender match someone (`after_roster_write`) — the held messages are replayed through the adapter that received them (`register_replayer`: Slack re-runs the held event, Discord and Telegram their held text, email re-fetches the message from Gmail and handles it as mail from a known sender), oldest first, now attributed to the new Person. A teammate gets every held message; a new contact only held email (it takes the private contact path; held chat is dropped); a decline drops everything. Replays run in a **fresh `contextvars.Context`**: the answer may have come from the principal's own verified turn, and a stranger's message must never be read with the principal's session — and their contacts — still bound.\n\n### Standing facts by email (confirm by reply)\n\nThe principal can ask for a standing fact, a retirement or a company-profile edit by email, and the fact tools check it exactly as in chat (the own-words gates run on `_email_memory_text`, the sender's new text, not the quoted chain or attachments). An email's From line proves nothing, though, and a standing fact is read by every later prompt as the principal's own account. So an emailed change is **held, never applied**, until the principal's own mailbox confirms it (`integrations.fact_confirmation`, `memory.facts.fact_confirmations`):\n\n- **Who may ask.** The poller stamps every email turn with `Session.email_from` (the bare lowercased From address) and `email_authenticated`. The text the poller reads never carries `Authentication-Results` (workspace-mcp prints a fixed set of headers), so for mail claiming the principal's primary address it reads the message again as raw MIME (`fact_confirmation.sender_authenticated`) and requires Gmail's own stamp: the **topmost** `Authentication-Results` header (Gmail adds it on receipt, above anything the sender wrote), authserv-id `mx.google.com`, reporting `dmarc=pass` for the From address's domain, with the raw From matching (`authenticated_by_gmail`). It fails closed: no header, `dmarc=none`, a temporary error or a failed read all leave the turn unauthenticated. Mail from a teammate other than the principal is read the same way, before its inbound audit row is written: authenticated, the whole handling of it is theirs alone in the audit log (`rows_for_person`, see Audit) and the turn reads their own mail as them (`Session.email_sender_authenticated`); unauthenticated, it keeps the ordinary rule. Mail from anyone else costs no second read and is never authenticated. `fact_tools._gate` admits an email turn only from the principal's **primary** address, exactly (no alias, no company-domain rule), authenticated, and not private to the principal (mail they forwarded stays refused, as do contacts, teammates and unknown senders).\n- **The hold.** The tool returns `awaiting_confirmation` (no action chip, and the model is told to say nothing has changed). `request_confirmation` stores the change with the SHA-256 of a fresh one-time token (`FC-` + 20 base32 characters, 100 bits; 7-day expiry; at most 10 waiting at once) and emails the token to the principal's roster address, outside any turn's outbound context. A profile edit is dry-run first, so one that cannot apply is reported in the turn, before any email goes out. If the email fails, nothing is held.\n- **Keeping the token from any model.** The tool result never carries it, and `mcp_gateway.hide_roster_tokens` now scrubs `FC-` tokens as well as `RR-` ones (`FC-[hidden]`) from every Google Workspace result except the poller's own fetch of an inbound message. Act as me's reads of the principal's own Gmail, where the confirmation email lands, hide them too (`delegation.gmail.parse_message` and the thread search), so the drafting model never sees a live token either.\n- **The reply.** `_handle_one_email` hands every message to `try_email_fact_confirmation` right after the roster-answer check, before any model turn. It acts only on a reply from the principal's primary address carrying the token of a **pending** confirmation. A made-up, spent, cancelled or expired reference matches nothing and falls through to the ordinary path with the token hidden, so no old or forged token can make the Executive email the principal. A reply with a live token is read again as raw MIME (`read_raw`) and must pass the same Gmail DMARC check as the request; one that doesn't (or whose raw read fails) is refused, the change keeps waiting, and the principal is told. Then an **automatic reply** is consumed quietly — no answer, no model turn, the change keeps waiting: the printed headers catch a Gmail vacation reply (`Precedence: bulk`), and the raw headers catch what they don't show (`automatic_reply`: `Auto-Submitted` other than `no`, `X-Auto-Response-Suppress` and similar, `Precedence: auto_reply`, an out-of-office subject), since an Exchange out-of-office echoes the token from the principal's own address and may well say \"I'll confirm on my return\". The raw message starts at the tool's exact separator line (`\\n\\n--- RAW MIME ---\\n`), so a sender-written Subject imitating it cannot supply the headers. CONFIRM (or yes / approve) applies the change through `fact_tools.apply_confirmed`, the same apply step chat uses. CANCEL (or no) drops it, and an unclear reply leaves it waiting. The principal gets a one-line result either way. A compare-and-set on the row means two replies can apply it only once.\n- **Audit.** Private `fact_confirmation` rows record held / applied / cancelled / refused. The applied change also writes the fact tools' usual private rows, with `source_channel=\"email\"` as its provenance.\n\n### Checking a channel is set up\n\nSettings → Setup status (`GET /setup/status`, `api/setup_checks.py`) gives each channel a light and a plain-language fix. It tests the credentials with one free read-only call — Slack `auth.test`; Telegram `getMe`, then `getWebhookInfo` (is a webhook registered, is it this app's `/webhook/telegram`, did a delivery fail in the past day); Discord `users/@me`, only while the bot is still connecting — and reads what the lifespan left on `app.state`: the Slack socket's `is_connected()`, the Discord bot's `is_ready()` together with its gateway socket being open (discord.py stays ready while it reconnects after a drop), or the exception its task stopped with (`LoginFailure`, `PrivilegedIntentsRequired`), and the MCP gateway. A listening Slack is amber while the listener hasn't resolved its own user id (`slack_bot.bot_user_id()`), since thread continuations are off until it does. Because inbound is roster-gated, a connected Slack, Discord or Telegram is amber while nobody on the roster has its id (`slack_user_id`, `discord_user_id`, `telegram_chat_id`), or when its latest `integration_inbound` row is a `Rejected: …` from the past day whose sender still isn't on the roster — the page shows the sender's id, never message text. Local login with no `TELEGRAM_WEBHOOK_SECRET` is red for Telegram, since the local-login gate turns its webhook away. Google Chat and Gmail are checked from configuration: a numeric project number and a readable service-account key; the OAuth client (or service-account key) the workspace-mcp child signs in with. Gmail is also amber when startup priming could not find every pinned Google tool (`MCPGateway.prime_pinned_tools`, whose result the gateway keeps on `pinned_tools`): the row names a few of the missing tools and counts the rest, and points at the `MCPGateway` log lines. It is amber too while that probe has not finished, and when the probe itself failed — the one check that reads green is the one that ran and found everything, so a crashed probe is never silently fine. The row speaks for the whole `google_workspace` server, so a missing Docs or Drive tool shows up under Email (Gmail).\n\nA channel setting written `KEY= # note` in a `.env` file arrives as the note's text, because python-dotenv strips only a comment that follows a value. `Settings` reads such a value as unset for the Slack, Telegram, Discord and Google Chat credentials, so the line no longer switches a channel on with its own comment as the token. `TELEGRAM_WEBHOOK_SECRET` is the exception, because read as unset a junk secret would turn the webhook's check off. It is kept instead, and since Telegram can only ever send 1–256 of `A-Z a-z 0-9 _ -` (what `setWebhook` accepts), a secret outside that set — a leftover comment, a pasted `` — could only be matched by someone who guessed it: while it is set the webhook refuses every update, neither the local-login gate nor the roster tools count it as proof (`Settings.telegram_webhook_secret_valid`), and Setup status shows it red (`.env.example` shipped four such lines; the notes now sit on their own lines).\n\n### Picking people and rooms by name (chat directory)\n\nNobody has to copy a raw Slack, Discord or Telegram id. The person page, Add person and a department's Channels settings pick them by name from what the bots this instance runs can reach (`integrations/chat_directory.py`, served at `GET /chat-directory/people`, `/channels` and `/slack/lookup`, the principal's alone). The id is still what is saved; the by-hand field stays under \"Enter an ID by hand\", and is all there is for a provider with no bot.\n\n- **Slack** — channels from `conversations.list` (public, plus the private ones the bot is in; scopes `channels:read`, `groups:read`, and public only when `groups:read` is missing) and people from `users.list` (no bots, no deactivated accounts; `users:read`, and `users:read.email` for their profile email). A person with a work email is matched by it (`users.lookupByEmail`, after the cached list): the picker pre-selects \"Found by email\" with a \"Not them?\".\n- **Discord** — text and announcement channels of the servers the bot is in (only `DISCORD_GUILD_IDS` when set) and their members over the REST API. Members need the Server Members privileged intent; when Discord refuses, the list is empty with `members_available` false, not an error.\n- **Telegram** — there is no list API, so the webhook records each chat an update comes from in `telegram_chats` (`integrations/telegram_chats.py`): private chats are people, groups and supergroups are rooms. Only a webhook whose `TELEGRAM_WEBHOOK_SECRET` checked out records anything, since without it anyone could post a made-up name for a chat id. A row is a suggestion only and grants nothing: the roster still decides who is answered.\n\nA department's Slack and Discord pickers pre-select the room named most like the department; it is saved only when the person presses Save. Lists are cached in-process for 60 s and capped at 2,000; a provider that fails reads as an empty list. Tokens go only in the Authorization header to the provider's own API host and are never returned.\n\n### Conversation state\n\nEvery adapter keys a deterministic `session_id` off the conversation surface and replays that session's stored turns into `Session.conversation_history`, so a follow-up (\"yes, go ahead\") lands with the question still in context. Slack's ids are `slack:dm:{user}`, `slack:thread:{channel}:{thread_ts}`, and `slack:channel:{channel}:{user}` for a mention that starts a thread; because the bot's reply roots a Slack thread at that message's `ts`, the turn is double-written to that thread's session too, so the first continuation inside it is not cold. A reply inside a thread THAT PERSON ROOTED also offers the parent `slack:channel:{channel}:{user}` id when matching an approval gate, since a gate raised on the mention recorded that session while the answer arrives under the thread's. The root-author check is load-bearing: widening on \"threaded and not a DM\" alone offered one person's channel session inside any thread in that channel, so a remark they made to a colleague could resolve their open gate and post the run's title into that colleague's thread. Discord has the same shape — its auto-thread router promotes a mention reply into a bot-owned thread — and the same alias. An @-mention inside a DM is handled once, by the DM path: Slack fires both events, and taking both forked the conversation across two session ids. The same id is the audit grouping key and the scope passed to `format_for_prompt`. A per-session `asyncio.Lock` serializes turns within one conversation — the inflight semaphore caps fan-out across the workspace, which does nothing to stop two messages in the same thread from both reading history before either writes it. Persistence happens after the reply is sent and its failure is logged, never surfaced: the user already has their answer.\n\n**Conversations within a chat app.** A Telegram chat, a Slack or Discord DM and a person's @mentions in one channel (`slack:channel:…`, `discord:channel:…`) are each one endless stream, so they are cut into conversations (`memory.conversations`, shared helpers in `integrations/conversations.py`). Each adapter computes the stream's base id as before and asks `conversation_for` which conversation a message belongs to: the newest, unless the stream has been quiet for two hours (`CONVERSATION_GAP`), when the next one starts. The first conversation keeps the bare base id; later ones add `~2`, `~3`, … (`memory.conversation_ids`). A whole message of `/new`, \"new chat\" or \"new conversation\" starts a fresh one instead of a turn and is answered with the name of the one it ended; the new row has no messages and stays off the Chats page until its first turn. After a conversation's first exchange `title_after_turn` names it in the background with `utils.chat_topics.name_chat` (the first words of the message if that fails), as a web chat's first turn does: the same call files it under one of its owner's chat topics, so a Telegram or Slack conversation sits beside the web chats about the same matter in the Chats page's Topic view. Threads (`slack:thread:…`, `discord:thread:…`), email and every other id are left whole. What has to see the whole stream compares base ids: the inbound resolver, so an approval asked before a cut is answered after it, and the session-scoped decisions and advice `format_for_prompt` renders. At boot `split_existing_streams` cuts history saved before this by the same rule, once (`app_migrations`), titling each part from its first message and carrying a read-mail lock to every part. An adapter installed outside the package adds its streams with `conversation_ids.register_rolling` and cuts its own old history once, with its own migration name and an `only` filter for its base ids, because the built-in sweep may already have run without it.\n\nThis was absent on Slack until #136. Every message built a fresh `Session` and minted a per-*message* id (`thread_ts` falls back to the message's own `ts`, so a DM never repeated one), which meant a multi-turn confirmation could not work: the Executive asked whether to send a briefing to Slack, the user said yes, and the next turn had no record of the question.\n\n### What the model is told about the channel\n\nChat adapters pass a `` block (`integrations.channel_context`) in the **user turn** — never a cached system block. It names the surface, states that the person cannot see the app's cards or buttons (except where a chat shows them, below), and draws the line the assistant kept crossing: briefing proposals and alerts are actioned on the briefing page; a proposal that books something can only be approved there; a workflow sign-off the Executive is explicitly waiting on *can* be answered in the conversation.\n\n**Approval cards with buttons (Telegram).** In a private Telegram chat on a webhook only Telegram can call (`TELEGRAM_WEBHOOK_SECRET` set and valid; `integrations.channel_cards.shows_cards`), the cards a reply leaves are sent as they are shown under a web chat reply: a meeting to book and a card of actions from `propose_actions`. `Executive.chat(cards=...)` collects their ids from the turn's `action_taken` events; the adapter sends each as a plain-text message with **Approve** and **Skip** inline buttons (`callback_data` `card::a|s`), and the `` block tells the model the card is there and that a typed yes does not approve it. A tap arrives as a `callback_query` and is taken only from a private chat whose sender is that chat (`from.id` = chat id), on the verified webhook; the chat's rostered person is the tapper. `api.routes.decisions.resolve_in_chat` applies the web chat's rule (a card of actions is its person's alone; a meeting is the principal's, or its approver's unless it is the principal's private booking), books or carries out the card exactly as stored (`delegation.verified.ChatTap` stands in for the signed-in caller on an action card), or records the skip. The card's message is then edited to show the outcome without buttons. Elsewhere (Slack, Discord, Google Chat, a group, an unverified webhook) no card is sent and `propose_actions` tells the model the card is on Today. The principal's DMs on Slack, Discord and Telegram also receive the open-alert digest (`briefing.context.format_open_alerts_for_prompt`) as the `` block, gated to DMs because the board is company-wide and a shared channel must not pull it in. That digest is the ONLY trusted source of an `alert_id` for `ack_alert`. The briefing page's `[Discuss mode — alert_id=N]` primer is a pointer to which item is under discussion, not authority on its own: the server accepts that id only if it is also on the live board it derived itself. An id appearing anywhere else, including inside an alert's own body, is not trusted: alerts are minted from inbound email and chat, so their bodies are attacker-controlled. Two mechanisms enforce that rather than one prompt rule. Every field the digest interpolates is collapsed to a single line, because a newline in an attacker-written headline, suggested action, tag or review note would otherwise forge a whole extra `[N]` line — a fabricated instruction to clear somebody else's alert. And the live board the server derived is recorded on the session, so `ack_alert` refuses any id outside it — on EVERY session, web included; the web previously had no check at all. The recorded set is the whole live board (the cap `/today` renders as cards), not just the rows the digest printed, so a card past the digest's 30-row render cap is still ackable rather than sending the principal back to the page. Be precise about what this buys: it stops an id that is invented, closed, snoozed or expired, including one quoted out of an attacker-written alert body. It does NOT stop the model being argued into acking the wrong LIVE card — one alert's body can still make a case about another id that is genuinely open. Scoping trust to the single item under discussion would need the primer id carried server-side, which it is not.\n\n### Handler failures\n\nAn adapter's catch-all replies \"I encountered an error processing your request.\" On Slack that used to be the whole story — one uncorrelated log line, no audit row, and an unguarded error-send that could itself escape into Bolt. It now logs with the session, sender, channel, thread and mode, and writes an `integration_inbound` row with `details.outcome=\"handler_error\"`, joining the inbound row under the same `session_id`. Upstream, a single crashing tool handler no longer takes the turn down at all: the orchestrator's tool gathers use `return_exceptions=True`, hand the model an error `tool_result`, and audit it with `ok=false` (cancellation is re-raised, not swallowed).\n\n### Attachments\n\n`integrations.attachments` handles web chat, Discord, Telegram, Slack and Google Chat. Images become Anthropic vision blocks; documents (PDF/DOCX/XLSX/TXT/MD/CSV) are inlined into the turn that carried them — inside an `\">` block (`content_trust.wrap_untrusted`, whose closing tag the text cannot forge), under an `[Attached: ]` label, which the open-loop pass reads as \"not the speaker's words\" and the decision extractor strips before it reads the turn — and indexed into the ChromaDB `inbound_attachments` collection. 20 MB/file cap; web chat uses `POST /chat/upload`.\n\n**Voice notes** are transcribed before the turn, since the model takes no audio. `providers.transcription` calls any OpenAI-compatible `POST {base}/audio/transcriptions` server: `TRANSCRIPTION_BASE_URL` (with `TRANSCRIPTION_API_KEY` / `TRANSCRIPTION_MODEL`) wins, otherwise OpenRouter when `OPENROUTER_API_KEY` is set (`elevenlabs/scribe-v2`), otherwise there is none and a voice note becomes a line asking the sender to type it. Recordings are capped at 25 MB and `TRANSCRIPTION_MAX_SECONDS` (300), the length read on the server from the Ogg, WebM, MP4 or WAV container (`recording_seconds`), since the servers bill by length and the browser's own stop can be skipped; a file whose length can't be read (mp3, flac, aac) is held to 16 KB per allowed second. A file can misstate its own length, so the bound that holds is bytes: at most `TRANSCRIPTION_MAX_MB_PER_DAY` (200) go to the speech server per UTC day, failed calls included. A voice note the sender recorded in the chat app (a Telegram voice or video note, a Discord voice message, a Slack clip; `AttachmentItem.voice_note`) becomes their own words (`[Voice note, transcribed]`; those senders are roster-gated). The reply is the answer alone, with no echo of what was heard. Any other audio (a Telegram audio file, an uploaded recording, anything on Google Chat) may hold someone else's voice, so its words are attachment text, untrusted-wrapped like a document's. An email's audio attachments (up to 2) are transcribed into attachment text, untrusted-wrapped like a document's. Each call writes one usage row (actor `transcription`, with the cost the server reports) and tells any `add_usage_listener` listener. The web chat mic records in the browser and posts to `POST /chat/transcribe`; the words go into the message box to read and send.\n\n**Scanned PDFs** are read, not reported empty. A PDF with no text layer goes through `knowledge.pdf_reader`: with `PDF_PROVIDER_READING=true` (opt-in, off by default) the deployment's own model reads it through its provider's PDF support (Anthropic natively, OpenRouter's `file-parser`, a local server only with `LOCAL_PDF_INPUT`); otherwise, and as the fallback, local OCR (pypdfium2 + RapidOCR, offline, no key) — so every deployment converts them. The label then reads `(converted from scanned pages)`, and an unreadable one says why. The index takes the text already extracted for the turn, so a scan is converted once. A file a channel delivered (everything here except `POST /chat/upload`, where the signed-in user sends it) converts at most `PDF_INBOUND_MAX_PAGES` (30) pages and draws on a rolling `PDF_INBOUND_PAGES_PER_HOUR` (300) budget shared by all senders, so sending scans cannot run up unbounded model spend or CPU.\n\nPer channel:\n- **Slack** — a `file_share` message (previously dropped outright for its `subtype`) is handled like any message; files download only after the roster gate and the response gate, with the bot token sent only to `https://files.slack.com/` (needs the `files:read` scope). History and peer memory record the words plus `(Attached files: …)`, never a document's text.\n- **Google Chat** — uploaded attachments download through `media.download` with the reply credentials (`chat.bot`), checked against the 20 MB cap per chunk, 5 per message; an attachment-only message is no longer dropped. The webhook authenticates Google, not the sender, so files are read only when the event's `user.email` is on the People roster (a team member); anyone else's files are named to the Executive, never downloaded. A Drive file shared into a message is left to the Drive tools, with its id.\n- **Email** — the poller (`integrations.email_attachments`) downloads up to 5 readable attachments of the message itself via `get_gmail_attachment_content` — workspace-mcp in stdio mode saves the file and answers `Saved to: `, which must start its line, be the last such line and end in the answer's `Saved filename:` (the answer echoes the sender-chosen attachment name first, and a name carrying `Saved to: /…` must not redirect the read), then resolve inside the tool download folders — and appends their text to the turn (15k chars each). Image attachments (PNG/JPEG/GIF/WebP) go the same way into vision blocks for the turn: up to 3, none under 10 KB (signature logos, tracking pixels) and none over 3 MB (the model API's 5 MB base64 limit). A registered mail source's `read_attachments` may return images too (`EmailAttachments`). Only for a sender the principal knows (team, contact, principal); a stranger's files stay a list. It runs outside the model loop, so it works on private turns without adding the attachment tool to `PRIVATE_TURN_MCP_TOOLS`.\n- Anything else (a Drive PDF, a skipped attachment) — the Executive's `read_document` tool.\n\nIndexed chunks are keyed on `attachment:` and tagged `type=attachment`. An attachment's name is chosen by whoever sent the message and the name is the chunk-id namespace, so without the prefix an inbound `strategy-2026.md` would upsert over the curated company document of that name. The prefix separates inbound content from curated uploads and keeps provenance visible; it does not separate senders from one another.\n\nIsolation is the **collection**: `retriever.retrieve` never queries `inbound_attachments` at all, so an attachment cannot resurface as company knowledge in a later, unrelated turn. Attachment processing is roster-gated on Discord too — it previously ran before the gate, so an unrostered sender's file was downloaded and indexed before their message was dropped. These rows still have no operator surface: they are never written to `company/docs/`, so `GET /documents` does not list them.\n\nThis replaced a domain-based scheme that did not work. The chunks lived in `company_docs` under a domain outside the specialist set, on the premise that an unrecognised domain matched no filter. It matched every one — `ChromaDBStore.query` attaches a `where` clause only when a domain filter is present, and an Executive-level retrieval passes none — so attachments came back under “From your company documents”, top-ranked and indistinguishable from a curated upload. Legacy rows are migrated across on boot.\n\n### MCP gateway & outbound\n\nMCP runs in **both directions**. As a *client*, `orchestrator.mcp_gateway.MCPGateway` proxies MCP servers (Gmail, Calendar, Drive, Outlook mail and calendar — whatever `mcp_servers.json` configures) as Executive tools, with egress gating on Gmail recipients, Calendar attendees, Drive share grantees, and Microsoft 365 recipients/attendees against the roster (and a refusal of public / whole-domain Drive sharing). The Gmail gate also accepts only a fixed set of argument keys, so workspace-mcp's `reply_all` (recipients taken from the thread, never checked) and `forward_message_id` / `include_forwarded_attachments` (forwarding any inbox message) are refused; a reply is `thread_id` plus an explicit, roster-checked `to`. Three more refusals close paths around those gates: every Apps Script tool (code running as the Executive's account, outside the gates, and since workspace-mcp 1.29.0 schedulable to keep running); any argument that makes the server fetch a URL (`fileUrl` / `file_url` on the Drive and import tools, a Gmail attachment given as a `url`), since the fetch can carry data out in its query string before any recipient gate matters; and text Google would mail to someone the roster never checked (`rsvp_comment` to an event's organizer, an out-of-office `decline_message` to whoever invites the Executive). Every gate reads its arguments by normalized spelling: workspace-mcp 1.29.0 renames a camelCase key such as `Attendees`, `userGoogleEmail` or `rsvpComment` to the real parameter, so a gate matching the exact key would never see it. Arguments that are not a JSON object are refused for the same reason. Google-side fetches are refused too: any key ending in `url` / `uri` at any depth whose value has a URL scheme (a Slides image or page background, a Docs or Forms image; a document hyperlink and a meeting link are stored, not fetched, and pass, as does a Drive file id), (a value is a URL after the characters a URL parser strips are removed, and `//host` counts), and on every tool that can write cells — the Sheets tools and the Drive create / update / import tools, which turn CSV into a Sheet — any string anywhere holding a formula call that fetches (`IMPORTDATA`, `IMPORTXML`, `IMPORTHTML`, `IMPORTFEED`, `IMAGE`), JSON-encoded or mid-CSV included: a string argument that encodes JSON is read as the server decodes it (one too large to read is refused), text is also scanned with character references decoded and comments / CDATA removed, and an upload that would become a native Sheet may only be text `content` — a `base64_content` or server-side `file_path` upload there is refused, since an XLSX hides its formulas behind zip members and XML encodings; a PNG to Drive or a DOCX to Docs is untouched. Not covered, unverified: whether Drive's HTML / Markdown import fetches an `` when it converts `content` into a Doc. A `manage_event` call that names no attendees is sent with `send_updates=\"none\"`, so the event's existing guests are not emailed text the gate never checked. A delete is sent the same way, except the typed `cancel_calendar_event` of a meeting the Executive booked, which keeps its cancellation notice. Google Docs, Sheets, Slides, Forms and Drive writes run only on files and folders the Executive's own Google account owns (`_check_google_file_write`, read through `get_drive_file_permissions` at call time, fail-closed; only a name starting with a read verb skips it), as OneDrive writes do. Someone added to People in the current turn is left out of the allow-list until the next turn. The allow-list (`_egress_sets`, built by `_roster_allow_set` as a `people.identity.RosterAllow`) is every team member's email and email aliases plus the Executive's own address, always, and — for a teammate — any address on one of the company's own domains whose local part (`+tag` dropped) matches theirs, the same rule inbound mail is matched by; a **contact's** email joins it only when `people_tools.contacts_reachable_now()` is true — a turn the principal started on a verified surface (web chat, their own Slack or Discord, a verified private Telegram chat), or inside `grant_contact_egress()` (the decisions approve route, when the approver is the principal). On any other turn — an inbound email, a teammate, the scheduler, a workflow, the alert review — a contact's address is refused exactly like a stranger's, so the refusal cannot reveal that it belongs to a contact (contacts are private to the principal). The single exception to the roster rule is the fixed roster-request acknowledgement above, under its one-shot `roster_ack_grant`. A turn about the principal's private mail (`Session.private_to_principal`) narrows the list to the principal's own address and the Executive's. Such a turn may call only the MCP tools in `schedule_tools.PRIVATE_TURN_MCP_TOOLS`: Google Workspace reads (`search_gmail_messages`, `get_gmail_message_content`, `get_events`, `list_calendars`, `query_freebusy`, and the Drive, Docs and Sheets reads such as `search_drive_files`, `get_drive_file_content`, `get_doc_content` and `read_sheet_values`) and the two Gmail tools this gate narrows to the principal (`send_gmail_message`, `draft_gmail_message`). It is an exact allow-list, not a server: workspace-mcp runs at the tier `WORKSPACE_MCP_TOOL_TIER` names, `complete` by default, where a Google Chat message to a space, a Docs, Sheets or Drive write into a file already shared with others, `manage_event` on a shared calendar (its gate checks attendees and pins notifications, nothing else) reaches people with no recipient check; and every other server has none at all (a Slack or Notion server, the default `fetch` server, whose URL can carry the turn's content, a server loaded at runtime). Its `search_tools` results show only the allowed tools (`workflows.tool_catalog.filter_search_results`; output it cannot parse reads as no match), and a `call_tool` naming anything else is refused with an error tool_result and an audit row private to the principal (`schedule_tools.private_turn_withholds`), a missing name included. `search_tools` and `call_tool` stay offered, so the tool list and its cached prefix do not change. The typed calendar tool (`_resolve_attendees`) and the chat DM tools (`message_person`, `send_telegram_message`, `send_discord_dm` and their person-id recovery) apply the same rule — a contact's id reads as an unknown one off the principal's turn — and a private turn may DM or invite only the principal (`PRIVATE_TURN_REFUSAL` otherwise); `message_person` never falls back to a \"Needs you\" alert for a contact, who cannot sign in to see it. The Google Workspace server (`workspace-mcp`) behind those Gmail/Calendar/Drive tools runs **co-located inside the API** as a stdio child of the gateway — fitting the single-tenant model (one install = one company = one exec Google account), so there's no separate service to operate. It's baked into the API image and launched by `docker/workspace-mcp-launch.sh`, which selects single-user OAuth (token persisted at `/data/google_credentials`) or service-account domain-wide delegation from `GWORKSPACE_AUTH_MODE`. The optional `WORKSPACE_MCP_TOOLS` (comma-separated service names, default `all`) limits which Google services it loads. The gateway forwards the Google secrets to the child via `_FORWARDED_ENV_VARS`. Every `google_workspace__*` call acts as the Executive's own account. workspace-mcp takes the acting account per call (`user_google_email`) and nothing used to check it, so under domain-wide delegation a model-chosen address could open anyone's mailbox in the domain; `mcp_gateway._check_acting_account` now refuses a value naming any other address — before every other gate, with an `integration_outbound_blocked` audit row. An absent value stays allowed (the server's own account).\n\n**What the model is told is connected.** `MCPGateway.start()` records the configured server names (`server_names`), and the Executive renders them with the channel settings into block 0's *Connected Systems* section (`prompts.connected_systems`; see the caching section). While Google is connected that section pins the Google tool names the Executive uses constantly (`GOOGLE_TOOL_MANIFEST`, narrowed by `connected_systems.google_tool_manifest` to the services and tier actually loaded) and tells it to call them with `call_tool` directly, with no `search_tools` round trip. The narrowing is the point: a name pinned for a service workspace-mcp never imported is not in the index, so the model calls it, receives extensible-mcp's “has not been discovered via search_tools” refusal, and has no way to square that with its own instructions. extensible-mcp only runs a tool one of the session's searches has returned, and remembers it for the session's life, so the gateway does that discovery itself: the lifespan starts `MCPGateway.prime_pinned_tools()` in the background once the gateway is up with `google_workspace` configured (an exact-name search per pinned tool, the way `workflows.tool_catalog.resolve` does it, over the same narrowed set the prompt advertises — `connected_systems.pinned_google_tools`, so priming never probes a service that was not loaded). A name neither the prompt nor priming can account for is logged once, naming both causes: excluded by `WORKSPACE_MCP_TOOLS` / `WORKSPACE_MCP_TOOL_TIER`, or renamed by workspace-mcp since the pin. A pinned call that still comes back with extensible-mcp's \"has not been discovered via search_tools\" refusal, for example before priming finished, is discovered and retried once. That happens after every gate has passed the same arguments, and only for pinned names: any other tool still needs the model's own search, and nothing here widens what a call may do, including the private-turn allow-list.\n\n**Microsoft 365** is the Outlook twin: `@softeria/ms-365-mcp-server` (a Node package, pinned) is installed in a throwaway Node build stage and only the `node` binary plus the package are copied into the API image, launched by `docker/ms365-mcp-launch.sh` as the `microsoft_365` entry with an **explicit tool allow-list** (`--enabled-tools`, an anchored regex) rather than the upstream `mail,calendar` preset — that preset also registers inbox forwarding rules, mailbox settings (external auto-reply), calendar sharing and event forwarding, which can address people the gateway never sees. Exactly the Outlook mail + calendar tools and the OneDrive tools are exposed (`microsoft_365__send-mail`, `microsoft_365__create-calendar-event`, `microsoft_365__search-onedrive-files`, …; hyphenated Graph endpoint aliases, proxied verbatim), and every admitted tool is either roster-gated or carries no recipient; the gate also covers the excluded writers in case an operator widens the list. Both servers can be configured at once. Auth is the MSAL device-code flow against the operator's own Entra app registration (`MS365_MCP_CLIENT_ID` is required; the server's built-in public client is refused); the refresh token persists at `/data/ms365_credentials` and is seeded once with `ms365-mcp-launch.sh --login` from inside the container — the server has a native device-code CLI, so there is no mint script and nothing to copy. The launcher scrubs the literal `$VAR` placeholders extensible-mcp leaves for unset variables (otherwise MSAL would be handed `$MS365_MCP_TENANT_ID` as an authority), and `MS365_MCP_EXPECTED_USERNAME` pins the bound mailbox. The egress gate for this family walks Graph's nested shapes (`body.Message.toRecipients[].emailAddress.address`, `body.attendees[]…`) — every email-shaped string outside the free-text fields must be on the roster, control characters and non-ASCII addresses are refused, unforeseen keys are still scanned (fail-closed) — and covers the whole write surface: send, reply, reply-all, forward, every draft tool, `update-mail-message` (it can PATCH recipients onto a draft) and create-/update-calendar-event; reads pass through. `delete-calendar-event` makes Exchange mail every attendee, so it reads the event and roster-checks the attendees first, except the typed `cancel_calendar_event` of that same event. Graph's reply, reply-all and send-draft actions carry no recipient in their arguments (the recipient is whoever the referenced `messageId` names), so for those the gate first reads the referenced message through the server and roster-checks the recipients Graph will derive (`replyTo` else `from` for a reply; `replyTo` and `from` together, plus the original to/cc, for reply-all; the draft's own to/cc/bcc for send-draft), refusing on any lookup failure; RSVP and cancel actions get the same treatment through an event lookup, because their comment is emailed to the organizer or every attendee; `download-bytes` is pinned to mail attachment bytes and OneDrive file content (`/drives/{id}/items/{id}/content`, optionally Graph's `?format=pdf` rendering), and a OneDrive result comes back as the file's extracted text rather than base64 — the attachment pin is what makes the poller's promise that an unrostered sender cannot be auto-replied to hold on Outlook as it does on Gmail; and the rendered inbound text quotes any forged `--- REPLY ---` line inside a body so the persona only ever reads the real block. Every mail write's `from`, `sender` and `replyTo` may name only the Executive's own address (the Outlook twin of the Gmail acting-account pin), so the model cannot send as a rostered colleague's mailbox or steer replies elsewhere; and `move-mail-message` into Deleted Items, Junk Email or Recoverable Items is refused, so a move cannot trash mail ungated. The server registers its account tools whatever its tool allow-list says, so the gateway itself refuses `login`, `logout`, `select-account` and `remove-account` (audited like any other refusal) and drops them from `search_tools` results — a chat turn cannot sign the mailbox out, switch accounts or start a sign-in; setup is only the launcher's `--login`, and the read-only `verify-login` / `list-accounts` stay callable. The example config's hyphenated patterns still deny `delete-mail-*`, `logout`, `remove-account` and `download-bytes-to-file` as defense in depth. A successful `send-mail` records the same outbound-context linkage Gmail does.\n\n**OneDrive in chat** (opt-in with `MS365_MCP_ONEDRIVE=true` and a fresh `--login`, since the server asks for its tools' permissions on every token refresh and a sign-in without file consent would otherwise fail mail and calendar too) matches what the Executive can do on Google Drive: search, list a folder, read a file, upload and edit, create folders, move, rename, copy and share; there is no delete tool. The launcher adds `Files.Read.All` at sign-in (`--extra-scopes`), so folders shared *with* the Executive can be read too; writes (upload, new folder, move, copy) land only in the Executive's own drives, checked against `list-drives` at call time, so a planted instruction cannot save company files into a stranger's shared folder. Sharing is gated like Drive sharing: `share-drive-item` invitees must be People on the roster and named by email (an `objectId` or `alias` grantee is refused, since nothing can check it), no `owner` role, and `create-drive-item-share-link` must be scoped to specific people (`scope: \"users\"`): an anonymous or whole-organization link, or one with no scope (the tenant default), is refused. An invite must require sign-in and grant only view or edit, a link must be a view or edit link, and `search-onedrive-files` takes plain words only, since its query goes into the request path unencoded. Files Act as me's `save_my_email_attachment` copied from someone's own mail into the Executive's Drive or OneDrive (`delegation.saved_files`) are that person's alone: on any other turn the gateway refuses a call whose arguments name a recorded file id and drops the entries or lines of a result that name one, failing closed for the file tools when the record can't be read. The folders those copies go in are made by the save (never adopted by name) and recorded, and a model call naming one, by id or by an OneDrive path through it, is refused on every turn, the owner's too, with their ids hidden from results. That tool's own calls are the only Drive and OneDrive writes a turn that read the owner's mail may make: built in code, admitted past the lockdown as those exact objects (`code_built_call`), and still checked by every gate above.\n\nAs a *server*, `openexecutive.mcp_server` exposes OE's own company context and specialist council to external MCP clients at `/mcp` — the inverse direction; see the dedicated **MCP Server** section. The gateway launches extensible-mcp as a `uvx` subprocess whose fastembed tool-search index uses an embedding model baked into the API image and loaded offline, so it no longer re-downloads that model from the Hugging Face Hub on every cold start. extensible-mcp is pinned twice over: to a commit, which fixes its own code, and with `--exclude-newer`, which fixes the resolution of its dependencies — without the cutoff uvx re-resolves them against PyPI on every networked start, so a new release of any of them would reach running deployments on restart. Both live in `_EXTENSIBLE_MCP_LAUNCH_ARGS` in `orchestrator/mcp_gateway.py`, the Dockerfile's pre-warm uses the same arguments (a unit test enforces the match), so an image whose pre-warm succeeded starts the gateway with no network. The pre-warm is best-effort (`|| true`): an image built while GitHub was unreachable fetches the pinned commit on its first start instead. Updating the gateway is a deliberate bump of the commit and cutoff; `uv` itself is not pinned. Because the MCP stdio client does not inherit the parent environment, `MCPGateway.start()` explicitly forwards the embedding-cache/offline env vars (`FASTEMBED_CACHE_PATH`, `HF_HUB_OFFLINE`, …) into the child.\n\n**Emailing an artifact.** A Gmail send or draft may carry `attachments=[{\"artifact_id\": \"alert:12\", \"as\"?: \"docx\"}]`. `call_tool` runs the recipient gate first; only if it passes does `_expand_artifact_attachments` replace each such entry with the rendered file (`content` base64 + `filename` + `mime_type`, workspace-mcp's own attachment shape), produced by `artifact_records.render_artifact_file` — the same renderer as `/artifacts/{id}/download`. Only artifact rows resolve (never an arbitrary alert), links have no file, and an entry mixing `artifact_id` with other keys is refused. A stringified or single-object `attachments` is normalised first, so an artifact entry can never reach workspace-mcp unexpanded (it would silently drop it). At most 5 distinct artifacts per email (repeats are dropped) and 15 MB rendered in total; rendering runs off the event loop via `asyncio.to_thread`. A refusal writes `artifact_attachment_refused`; a successful send writes `artifact_attached` — both carry the recipients. Other attachment entries pass through unchanged. The same expansion serves the Microsoft 365 mail writes: the entry becomes a Graph `#microsoft.graph.fileAttachment` (`name`, `contentType`, `contentBytes`) placed where Graph expects it for that tool (`body.Message.attachments` for `send-mail`, reply, forward and the createReply drafts; `body.attachments` for `create-draft-email`), whether the model put it at the top level or inside the message; an entry anywhere else, one on `update-mail-message` or `send-draft-message` (their requests cannot carry attachments), or one behind a body that is not an object is refused rather than forwarded unexpanded.\n\n**Enablement, and why it degrades instead of dying.** `MCP_ENABLED` left unset is inferred from the presence of `mcp_servers_config_path` — dropping an `mcp_servers.json` next to `profile.yaml` turns MCP on with no further configuration. An **explicit** setting always wins in both directions: `MCP_ENABLED=false` keeps MCP off with the file still in place. It did not: `_resolve_mcp` tested `not self.mcp_enabled`, which cannot tell \"never set\" from \"set to false\", so the file's presence silently overrode the operator. The file is probed through `config.mcp_config_file_present`, never `Path.exists` directly — that re-raises an EACCES on a parent directory, and from inside a validator that comes out of `Settings()` itself, which `api/main.py` calls at module level. The check now reads `model_fields_set` (the fields the env actually supplied) and stores that on a private attr behind the read-only `mcp_auto_enabled` property, because the validator's own assignment joins that set and would otherwise make every auto-enable look explicit downstream.\n\nStartup is **best-effort**, in `main._start_mcp_gateway`. Two checks come before the subprocess: MCP off is a no-op, and a config defining no servers under `mcpServers` is logged and skipped — extensible-mcp's own loader raises on that and exits, and because the child is already gone the only thing that surfaces is anyio's \"Attempted to exit cancel scope in a different task\" from `stdio_client` unwinding, a traceback naming nothing about configuration. The start is also bounded (120s, generous because that child may still clone extensible-mcp on a cold start if the image's pre-warm failed): `ClientSession` carries no `read_timeout_seconds`, so `initialize()` waits forever on a child that is *alive but silent*, and an unbounded wait is a lifespan that never yields — the same healthcheck-never-passes symptom, which no `except` can see. A gateway that fails anyway is logged at ERROR with the config path, the servers it named, and whether the config file or an explicit `MCP_ENABLED` turned MCP on, then torn down under its own timeout; the API boots **without MCP tools and without the email poller** rather than refusing to boot. The guard catches `BaseException`, not `Exception`, re-raising a bare `CancelledError` first: the original failure came from anyio, which wraps a task group's failures in `BaseExceptionGroup` as soon as one is a `BaseException` — and that group is not an `Exception`, so `except Exception` would miss the very shape this contains. The teardown is contained too, since it runs from inside that `except` and would otherwise replace the actionable log with the crash it prevents. `configured_server_names` never raises either, and deliberately catches broadly: it reads an operator-owned path during startup, where an exception is the crash loop rather than a bug report — `Path.is_file` re-raises an EACCES on a parent directory, and `json.loads` answers deeply-nested input with `RecursionError`, which is not a `ValueError`. It tests `is_file()` so a directory or a symlink to a FIFO is never opened, and caps the read at 1 MiB. That exception used to propagate out of the lifespan and kill the container — under `restart: unless-stopped`, a crash loop whose only outward symptom was a healthcheck that never passed. Outbound tools: per-Person DMs (`send_slack_dm`, `send_discord_dm`, `send_telegram_message`) plus room-targeted `send_department_message` and `send_company_broadcast`. All three per-Person DMs refuse a recipient id that matches no non-archived Person row (`schedule_tools._dm_recipient_on_roster`), which is what stops untrusted content in a turn — inbound mail, a chat message, a knowledge document — from directing a DM at an arbitrary workspace member. `send_slack_dm` previously consulted the roster only when the turn was already private to the principal, so on an ordinary turn it applied no roster check at all while its Discord and Telegram siblings did; the room-targeted tools are deliberately ungated, since those channels are team-visible. The per-Person DMs are additionally rate-limited and de-duplicated by the outbound anti-spam guard (below).\n\n### Outbound anti-spam guard\n\nEvery per-Person DM passes a deterministic guard (`orchestrator.outbound_guard.check_outbound_allowed`) after the roster gate and recipient-id recovery but before the network send — the single chokepoint **all** proactive DM paths reach (ad-hoc chat sends, the scheduler's followup/nudge dispatch, principal-brief delivery, the reflection turn, and research-finding routing). On a violation it returns a human-readable reason and the handler **suppresses** the send (`{\"status\":\"suppressed\",\"reason\":…}`), writes a `tool_invocation` audit row with `suppressed=true`, and writes **no** `done` activity row — so a blocked attempt never counts toward its own rate cap, and the Executive can read the reason and hold or `schedule_followup` for the recipient's next window. Three controls: **content dedup** (a near-identical message — whitespace/case-normalized, first 160 chars — already sent to the same recipient within `OUTBOUND_DEDUP_WINDOW_MINUTES`, default 360); a **per-recipient rate cap** (`OUTBOUND_MAX_PER_RECIPIENT_PER_WINDOW`, default 5, over `OUTBOUND_RATE_WINDOW_MINUTES`, default 60); and **quiet hours / availability** (`OUTBOUND_RESPECT_QUIET_HOURS`, default on — refuse while the recipient is on leave or outside their `people.channel` availability windows, surfacing the next open window). Delivery history is read from the existing `status='done'` `scheduled_actions` rows (`episodic.recent_sends_for_channel_ref`), so there is no new table. The guard is **fail-open**: any internal lookup error returns \"allow\", so it can never turn a working send into a crash or a silently-dropped message. Principal briefs and the internal heartbeat kinds use the `__internal__` channel and never reach these handlers, so they are unaffected. Once the guard allows a send, the handlers apply the recipient's own notification settings to the Executive's own (tagged) outreach: in their quiet hours, or when they chose *In my briefs*, the DM is stored and returned as `{\"status\":\"held\"}` rather than sent, and goes when quiet hours end or with their next brief (`outbound_guard.check_hold`; see Org).\n\n### AI disclosure\n\nWhat the Executive sends says it is an AI assistant (`orchestrator.disclosure`), added by plain code so a reply the model writes can't leave it out. **Mail** from its own mailbox gets a line under the body, by default \" is an AI assistant working for . To reach a person, reply and ask, or write to .\": the MCP gateway adds it to every Gmail send and draft and every Outlook send, reply, forward and draft (`mcp_gateway._with_disclosure`) **after** every outbound gate has passed and only on what goes to the server, so the gates, the outbound-context record and the audit see the model's own words, and `send_from_executive` gets it the same way. It counts as already there only when it is the last line that shows (in a quoted earlier message or hidden in markup it doesn't), and a send of an existing draft and mail whose every recipient is the principal go as they are. HTML that could hide what follows (an unclosed comment, a style or script block, text styled out of sight: `disclosure.hides_what_follows`) is refused, so the line can't be hidden after it; the model is told to send plain text. The roster acknowledgement to a new sender, the one send to an address nobody checked, gets `BARE_MAIL_LINE` (\"This message was sent by an AI assistant.\"), which names no one, like the acknowledgement itself. **Chat** gets a shorter line (\" · AI assistant\", in italics) on the Executive's replies in Slack, Telegram, Discord and Google Chat and on its own DMs and room posts (`send_slack_dm`, `send_discord_dm`, `send_telegram_message`, `broadcast_tools._dispatch`), but only on the first message in a conversation and again after 24 hours without one (`with_chat_line`, kept in memory, so a restart shows it once more); session history keeps the reply without it. `AI_DISCLOSURE` (default on) turns it off; `AI_DISCLOSURE_MAIL_TEXT` / `AI_DISCLOSURE_CHAT_TEXT` reword the lines, and a wording that doesn't say \"AI\" is ignored for the built-in one. It fails open: a line that can't be built is left off, never a send refused. Replies sent *as a person* under Act as me are the delegation section's (**AI line on replies**).\n\n### Calendar & video links\n\nThe Executive books meetings through two typed tools over the configured calendar backend (`CALENDAR_PROVIDER`: Google Calendar via `manage_event`, or Outlook via `create-calendar-event` — see *Workspace providers*). `create_calendar_event` schedules a **future** meeting (propose→approve by default, or auto-execute once that decision class is promoted) and may be used proactively when a stalled goal warrants a sync. `create_instant_meeting` spins up an **impromptu call starting now**, auto-executing without the approval step and skipping the business-hours/horizon caps, so the Executive can hand back a join link immediately. Because nothing approves it, an instant meeting, and `cancel_calendar_event` too, runs only when someone on the team asked on a surface that verified who they are (the principal, or a rostered teammate in the app or their own chat); a turn an inbound email started gets a refusal to pass on instead, and a team-mode `create_calendar_event` the operations department would auto-execute is proposed instead. Both attach a video-meeting link by default (`add_video_link`; opt out for in-person) — a **Google Meet** link read from `manage_event`'s plain-text reply (which also carries the event id, inside the event link's `eid`), or a **Microsoft Teams** link from `onlineMeeting.joinUrl` — surfaced to the caller plus persisted on the decision ledger as `meet_link`. The tool descriptions are static, backend-neutral text, so the cached tool block never depends on settings; the pre-rename `add_google_meet` key is still honoured for old ledger rows. Either way the caps that matter still hold — roster membership, principal-protection, max attendees, and a daily booking ceiling. A proposal raised in the web chat by the person who approves it (the principal, or its approver when it isn't the principal's private booking) says `approve_here`; its `action_taken` chip then carries the `decision_id`, and the chat shows the approval card (Approve, Edit, Dismiss) under the reply as well as on the briefing — answering either clears both. A booked meeting whose event id was never read can't be cancelled from the app: both cancel paths refuse rather than report a cancel that left it on the calendar.\n\n**Around-the-meeting participation.** The Executive doesn't join the call as a live voice; instead, on every successful booking it schedules a best-effort post-meeting follow-up (~5 min after the end) that DMs a human attendee asking for a quick recap — key decisions and action items. The reply flows back through the same cross-session DM context linkage described below, so the Executive can act on it (summarize, follow up) in a later turn. No meeting-transcript or Drive integration is required.\n\n### Workspace providers\n\nThe Executive can *discover* any mail or calendar tool the gateway exposes, but three code paths call a fixed backend with no model in the loop and used to spell Gmail tool names inline: the inbound mailbox poller (list unread → fetch → mark read), alert email dispatch plus the scheduler's email send-tool hint, and the typed booking tools plus the approval-time conflict check. `integrations.workspace` puts those behind two Protocols (`MailProvider`, `CalendarProvider`) with one module per backend (`google.py`, `microsoft.py`) and a registry keyed on `EMAIL_PROVIDER` / `CALENDAR_PROVIDER` — separate switches (Outlook mail with a Google calendar is legitimate), both defaulting to `google`, so an existing install changes nothing until an operator opts in. The Google backend is the old inline code moved verbatim; tests pin its argument dicts byte-for-byte. The Microsoft backend polls only the Inbox folder (`list-mail-folder-messages` with `isRead eq false`, never the all-folders listing that includes Junk), converts Graph's HTML bodies to text with a real parser, lists a message's attachments so the names appear in the same `N. name (mime, size KB)` shape Gmail's text uses (and reach peer memory), marks read with `update-mail-message`, sends through `send-mail`'s nested shape, books with `isOnlineMeeting` + `onlineMeetingProvider=teamsForBusiness`, cancels with `delete-calendar-event`, and answers the advisory conflict check from the Executive's **own** calendar view (delegated permissions cannot read attendees' free/busy — narrower than Google's `query_freebusy`).\n\nEvery backend renders an inbound message into the same `Subject:` / `From:` / `To:` / `Cc:` + `--- BODY ---` text the persona already expects, and the poller adds a `--- REPLY ---` block naming the exact reply tool and threading ids for *that* message (Gmail: `send_gmail_message` + `thread_id`; Outlook: `reply-mail-message` + `messageId`) after the mail's `` block, never inside it. The block rides in the user turn — which is what lets the cached persona say \"use the reply tool in the REPLY block\" instead of naming a vendor tool — and the scheduler's proactive-email trigger names the backend's send tool the same way. Providers never raise on a malformed backend response (they return empty / `None` / `{\"error\": …}`), so one bad cycle never kills a loop, and a chosen backend whose MCP server is missing from `mcp_servers.json` is logged at startup and skipped by the poller: fail-soft, never a boot failure. The poller's newer checks run on that rendered text for both backends (contacts and private turns, roster requests and their emailed answers, the audit row), with three Outlook differences that fail closed: no sender is ever authenticated (the `dmarc=pass` read is Gmail's), so there is no acknowledgement to an unknown sender, no emailed fact confirmation and no principal-authenticated mail; attachments stay a list (the download is Gmail's); and the brief's calendar block is skipped (its listing is Google Calendar's). The code's own emails to the principal (roster prompts, fact confirmations, scheduled deliveries) go through `workspace.registry.send_from_executive`, and `runner.email_ready` follows `EMAIL_PROVIDER`. A private turn's allow-list (`schedule_tools.PRIVATE_TURN_MCP_TOOLS`) and Act as me's mail-touched reads include the Outlook twins of the Gmail and Calendar tools, and roster answer tokens are hidden from Microsoft 365 results as from Google's.\n\n### Cross-session DM context\n\nWhen the principal says \"DM Alex about X\" in one session, OE sends the DM and — if a live conversation is active and the recipient is not one of the principal's contacts (a contact cannot DM the bot, and quoting the principal's conversation into their email reply's unattended turn would put it in the audit log) — records an `outbound_context` linkage row (recipient, full outbound text, originating session id). Email is covered too but recorded one layer deeper — there is no `send_email` tool, so the linkage is written in `orchestrator.mcp_gateway` after a successful `google_workspace__send_gmail_message` or `microsoft_365__send-mail`, one row per `to`/`cc` recipient keyed by the bare lowercased address (OE's own address skipped). When Alex later replies on the 1:1 DM path — or by email — the shared helper `integrations.inbound_hydration.hydrate_user_message` (called by the Discord, Telegram, and Slack handlers, and by the email poller keyed on the sender address) looks up the most-recent `open` linkage for that sender within 72h and prepends an `` block — the outbound text plus a backstory excerpt from the originating session — to the message handed to `Executive.chat()`. So OE knows what the reply is about instead of starting from an empty per-person session. The block is context for the LLM turn only: peer memory strips it before recording what the person said (`strip_outbound_reply_context`, applied in `sync_turn`, `sync_department_turn` and to the prefetch query), so the Executive's own DM is never attributed to the person who replied to it. The block lands in the user turn (caching rule) and is injected into a copy only — the pristine reply is persisted to history. The linkage is one-shot (consumed on first matched reply, race-safe) so an unrelated later DM isn't re-hydrated. This is deterministic and independent of the per-person `peer_memory` (Honcho) layer.\n\nEvery adapter that persists a turn (Slack, Discord, Telegram, and the web route) records the message's **resolved sender** on `chat_messages.sender_person_id` — the rostered Person the adapter matched, or NULL for an unrostered sender — independently of the session owner, which falls back to the principal on Discord and Telegram so those threads stay visible. Attunement's open loops and feedback rely on this so an outsider's words are never read as someone on the roster's.\n\n### OneDrive folder sync\n\nThe Microsoft twin of the Drive folder sync below (`knowledge.onedrive_sync`, `ONEDRIVE_SYNC_ENABLED`; setup in `docs/onedrive_sync_setup.md`). It copies the files in `ONEDRIVE_SYNC_FOLDERS` (`/` pairs; `openexecutive onedrive-folder ` resolves a sharing link) into the isolated `onedrive_docs` collection on its own heartbeat, and shows them on the knowledge page as \"OneDrive\" rows with a **Sync now** button. Microsoft has no service account that sees only what is shared with it (app-only Graph access covers the whole tenant), so it reads as the Executive's own Microsoft 365 sign-in, and the folder list is the scope. It never stores a credential: each tick asks the launcher for a token (`ms365-mcp-launch.sh --access-token`, which runs `docker/ms365-access-token.mjs` through the server's own token cache and will only mint file-read tokens), keeps it in memory, and sends it only to Graph. `knowledge.onedrive_client` issues GETs only, follows a paging link only back to Graph, and fetches a file's pre-authenticated download link without the token; errors never carry that link. PowerPoint and older Office formats are read through Graph's PDF rendering.\n\n### Google Drive folder sync\n\nThe synced files show on the knowledge page's Company documents list as \"Google Drive\" rows (sync time, last edit in Drive, an Open in Drive link and a text view), and the page's source card shows the last run, any sign-in error or count of files that failed to sync, and a **Sync now** button that runs one tick on demand.\n\n\nSeparate from the Drive tools the gateway proxies. The optional folder sync (`knowledge.drive_sync`, `DRIVE_SYNC_ENABLED`; setup in `docs/drive_sync_setup.md`) copies the files in `DRIVE_SYNC_FOLDER_IDS` into the isolated `drive_docs` knowledge collection on a scheduler heartbeat, so the Executive knows those folders without searching Drive live (see RAG). It calls the Drive v3 API directly (`knowledge.drive_client`, `httpx`) as a **service account** with the `drive.readonly` scope (`DRIVE_SYNC_SERVICE_ACCOUNT_FILE`), not as the Executive's own account through workspace-mcp. Google enforces the folder scope, because the account sees only what is shared with it, and the sync can change nothing. Structured JSON means no display text to parse, and every file or folder id is checked against `[A-Za-z0-9_-]{1,128}` before it goes into a URL or a query. The two paths meet in two places. A synced chunk carries its Drive file id and sync time, so the Executive can open the live file with its own `get_drive_file_content`. The setup doc says to share each synced folder with the Executive's account too, so that live open works.\n\n### Confluence space sync\n\nThe optional Confluence sync (`knowledge.confluence_sync`, `CONFLUENCE_SYNC_ENABLED`; setup in `docs/confluence_sync_setup.md`) copies the pages of the spaces in `CONFLUENCE_SYNC_SPACE_KEYS` into the isolated `confluence_wiki` knowledge collection on a scheduler heartbeat, so the Executive knows the company wiki without a tool call (see RAG). It works with Confluence Cloud and Server/Data Center: `knowledge.confluence_client` calls the v1 REST API both serve, directly with `httpx`, signing in with a personal access token (Server/DC) or a username and API token (Cloud). It reads only, never follows a redirect (so the token stays on the configured site), streams responses with a 20 MB cap, and checks page ids and space keys before they reach a URL or a CQL query. The URL must be `https://` unless `CONFLUENCE_SYNC_ALLOW_HTTP` is set, and `CONFLUENCE_SSL_VERIFY` takes `true`, `false` or a CA bundle path for a private certificate authority. With `CONFLUENCE_SYNC_PUBLIC_HOSTS_ONLY` (off by default, for an install where whoever sets the URL must not reach the backend's own network) every request resolves the site's host, refuses it when any address is not public (loopback, private, link-local, shared), and connects to the checked address with the site's name kept for the Host header and the TLS certificate check, directly rather than through a proxy. `confluence_client.config_problem` is the same settings check the app runs at start, for a caller that writes these settings on someone's behalf.\n\nIt is separate from a Confluence MCP server such as mcp-atlassian, which a deployment can add to `mcp_servers.json` for live reads and edits in chat. The two share the env names `CONFLUENCE_URL`, `CONFLUENCE_PERSONAL_TOKEN`, `CONFLUENCE_USERNAME`, `CONFLUENCE_API_TOKEN` and `CONFLUENCE_SSL_VERIFY`, so one set of values serves both, and a synced chunk carries its page id so the Executive can open the live page with such a tool. A Confluence token is a user's, and what the sync reads becomes retrievable by everyone who talks to the Executive, so the space list is required and pages with a read restriction (their own or a parent's) are skipped unless `CONFLUENCE_SYNC_SKIP_RESTRICTED=false`. That keeps any existing member's token safe to use, without a dedicated licence. Manual runs: `openexecutive sync-confluence` / `purge-confluence`.\n\n### Your own Gmail or Outlook (Act as me)\n\nA second, separate mail connection, for **Act as me** (see **Act as Me (Delegation)**): a person's *own* mailbox, which `delegation.gmail.DelegateGmail` reads and drafts into over the Gmail REST API. It is never registered with the MCP gateway, so the model can reach it only through the typed `ghostwrite_email` handler — never by tool name through `call_tool`. The credential is one file per person in `DELEGATION_GOOGLE_CREDENTIALS_DIR` (default `company/delegation_google/`, `/data/delegation_google` in Docker), minted on a computer with a browser by `scripts/connect-own-gmail.py --email ` with the Executive's own OAuth client and the scopes `gmail.readonly` and `gmail.compose` — the same seed-it-yourself flow as the Executive's token, since the API serves no OAuth callback. It never lives in `WORKSPACE_MCP_CREDENTIALS_DIR`, where workspace-mcp picks a credential by address.\n\nEvery use re-checks with Google that the credential opens the mailbox on the person's People entry, and refuses outright when that address is the Executive's own: a shared mailbox would make everything the Executive writes look like theirs. Drafts are threaded (`In-Reply-To`, `References` of whole ids ending with the parent, `threadId`) and stamped `X-OE-Ghostwritten`; the only send is `drafts.send` of an existing draft by id, when the person taps Send on its reply card (`delegation.reply_send`). A Gmail rate limit, even as a 403, is an error to retry rather than a reconnect. The voice learner and the thread shown to the ghostwriter keep only each sender's own words through `email_poller.sender_new_text`, the poller's quoted-reply stripper made public. Setup status shows it as **Your own mailbox (Act as me)**.\n\nThe same file can instead hold an Outlook sign-in (`\"provider\": \"microsoft\"`), minted by `scripts/connect-own-outlook.py --email ` with a device-code sign-in on the Executive's own Entra app (`MS365_MCP_CLIENT_ID`, `MS365_MCP_TENANT_ID`, default `common`) and the delegated scopes `User.Read`, `Mail.ReadWrite`, `Mail.Send` and `offline_access`. `delegation.gmail.gmail_for` then hands back `delegation.outlook.DelegateOutlook`, which talks to Microsoft Graph directly — never the Microsoft 365 MCP server the Executive's own Outlook uses — with the same methods and the same one send: Graph's `send` on an existing draft, with no body. Microsoft rotates the refresh token on use and the new one is written back into the file. A sender counts as verified only from the topmost `Authentication-Results`, the one Exchange wrote, and only mail in their Sent Items counts as theirs.", + "markdown": "External channels connect callers to the Executive. Each inbound adapter resolves the sender to a Person — the chat adapters via `find_person_by_{slack,discord,telegram}_id`, the email poller via `people.identity.resolve_email_sender` (a primary address or an alias, then the company-domain rule; see Org) — and threads `person_id` into `Executive.chat()`. A sender who resolves to nobody is no longer simply dropped: see **New senders (roster requests)** below.\n\n### Inbound channels\n\n- **Slack** (`integrations.slack_bot`) — async Bolt app (`AsyncApp`) embedded in the lifespan, hearing Slack one of two ways: socket mode (`AsyncSocketModeHandler`, with `SLACK_APP_TOKEN`) or, with only `SLACK_SIGNING_SECRET`, the Events API over HTTP at `POST /webhook/slack` (`integrations.slack_events`), where the route checks each request's signature and age (and a 1 MB cap) before Bolt sees it, so unsigned traffic never reaches Bolt's logging; it then takes each signed request once, answering an identical replay (same signature) or Slack's re-signed retry of an `event_id` it already took with an empty 200 that never reaches Bolt (an in-memory, per-process set kept for twice the signature window and capped in size; keys are forgotten if Bolt fails, so a retry of a failed delivery still runs), and Bolt acknowledges at once while the listener runs. Either way a repeated event id is dropped (Slack resends what it thinks went unacknowledged), as is an event delivered for another workspace. Responds to `@mention`s, DMs, and thread continuations, with per-conversation session memory (see **Conversation state** below). Text only: there is no interactivity endpoint and no Block Kit, so nothing Slack sends carries a button.\n- **Discord** (`integrations.discord_bot`) — discord.py gateway embedded in the lifespan; per-thread auto-titling, session-scoped memory, author attribution, configurable `@mention` thread promotion.\n- **Telegram** — webhook gating + per-chat history. Replies (and every outbound Telegram message) go out as Telegram's HTML subset (`utils.markdown_telegram`: markdown-it-py with raw HTML and images off, text escaped), so the Executive's Markdown shows as formatting; links show as their text and target in plain text and never become tags, so quoted text can't hide where one goes; a chunk Telegram refuses is sent again as plain text.\n- **Google Chat** — webhook + service-account auth. It resolves no approval gates and has no roster gate (the webhook authenticates Google, not the sender), and it never populates the session's trusted alert ids, so `ack_alert`'s trust check — which now runs on every session rather than only those declaring an `origin_channel` — refuses there by default, and its `` block says plainly that a sign-off cannot be answered from it. It does look its sender up on People by the Google account email on the signed event, but hands that entry only to the department gate (`Session.gate_sender_person_id`): the principal's own sends and edits go, a teammate's meet their department's authority, and anyone else's wait for approval. It is never `caller_person_id`, so it grants nothing else.\n- **Email** (`integrations.email_poller`) — MCP poll loop over the Executive's own mailbox — Gmail via `workspace-mcp` or Outlook via `ms-365-mcp-server`, chosen by `EMAIL_PROVIDER` (see *Workspace providers* below); it starts only once the MCP gateway is up, never surfaces Reply-To redirects, filters auto-replies. Its sessions carry `origin_channel=\"email\"`: the untrusted-content policy (`orchestrator.content_trust`) reads that tag to tell mail from the principal's own surfaces — left empty, as it once was, every stranger's body reached the decision extractor as the principal's words. The mail goes into the turn inside an `\" author_verified=\"no\">` block that says it carries none of the principal's authority, with the framing line and any `[POLICY]` notice outside it; mail from the principal's primary address that Gmail authenticated (`dmarc=pass`) is their own words — but only their new text goes in unwrapped: when the mail quotes or forwards another message (`_new_text_lines` cut something), the message as received follows in an `` block, since a stranger's text they replied to or passed on is not theirs. While the Executive is paused (`scheduler.pause`) it skips each poll and leaves the inbox unread, so held mail is processed on the first poll after resume; the chat adapters above are not paused. The poller handles one message at a time, so each Executive turn on an email has a deadline, `EMAIL_TURN_TIMEOUT_S` (300 s): past it the turn is cut off, an `integration_inbound` row with outcome `turn_timeout` records it, and the message is marked read and not run again (it may already have replied). A message whose handling keeps raising is given up after three tries in a run and marked read, rather than retried every poll. Each gateway call it makes is bounded by `MCP_TOOL_TIMEOUT_S` (see Lifecycle). The Executive sees the whole message; peer memory records only `_email_memory_text` — the subject, the sender's new text (cut at the first quoted or forwarded message) and the attachment filenames — parsed from the rendered message with any trailing `--- REPLY ---` block dropped first, without backtracking-prone regexes, since the text is sender-controlled, and only for a rostered sender, the only kind with a peer to record into. The poller's `integration_inbound` row keeps the mail's text for the audit log, the way the reply's `chat_turn` row keeps the Executive's answer: `full.message` is the sender's new text (what web chat keeps for a turn), `full.attachments` the attachment filenames, and `details.preview` the first 120 characters of the new text, so /audit shows what was asked next to what was answered (the summary still reads `Inbound email from : `). The whole body with its quoted chain (`full.body`) is kept only on a row private to the principal: the chain is other people's mail — a contact's, on the principal's reply to one — and every signed-in user reads a public row. Each field is cut by its escaped JSON length (20,000 characters of text, 8,000 of attachment names), the measure the logger's 64 KB cap uses, so a long mail in a non-Latin script loses its tail rather than the row's structure. Outbound, the principal briefs and the client-rotation digest are emailed through the same gateway with the configured backend's send tool (`google_workspace__send_gmail_message` or `microsoft_365__send-mail`, via `workspace.registry.send_from_executive`, from the Executive's own mailbox to the principal's address, subject ` — `, the date in the user's zone) as formatted HTML — rendered from the brief's Markdown with raw HTML and images off, so text quoted from outside shows as text and opening the email loads nothing remote. Email is first when the principal prefers it, and otherwise the backup after every chat channel, tried only when Slack, Discord and Telegram are missing or fail; an in-band error from the tool counts as a failed send and the next channel is tried. An emailed brief is not a DM, so the per-Person outbound guard below does not apply — the gateway's roster egress gate does.\n\n Two senders get their own framing. Mail from one of the principal's **contacts** (a `kind=\"contact\"` People row — see Org) replaces the non-roster `[POLICY]` notice with a contact one: it names them (name and role/company), says not to reply unless the principal asks and not to tell anyone else, and asks for a summary in an alert for the principal instead. Mail from anyone not in People, a contact or a stranger, is held as outside content for its whole turn (`orchestrator.outside_content`): no connected-app write, contact change, follow-up, link, script or workflow, no direct message, only reads and an alert private to the principal. Both this turn and the forwarded-by-principal turn below are marked `Session.private_to_principal`: they can email, DM or invite only the principal, an alert they raise is private to the principal, and they are not offered the tools that reach anyone else or start work outside the turn (`schedule_tools.PRIVATE_TURN_WITHHELD_TOOLS`: broadcasts, meeting bookings, workflows, follow-ups, watches, artifacts, goal updates, skill drafts, connecting a new MCP server), and of the MCP tools only Google Workspace reads and the gated Gmail send (see MCP below). Every audit row about such mail is private to the principal (see Audit Log), the poller's two `integration_inbound` rows included (`_private_to_principal_mail`), so the audit log's other readers see none of it. The poller holds `audit.private_rows` for the whole handling of such a mail, because some rows are written before the turn binds its private session: the knowledge retrieval quotes the mail's From line, subject and opening. The rows themselves read exactly like an outside sender's: the `integration_inbound` row is the non-roster one (`accepted_non_roster`), and the notice opens with the non-roster notice's exact sentence. The poller handles each message with no session bound, so one message's private turn never marks the next message's rows. The turn stays unattributed (`person_id` None — no peer memory, no co-presence), and the gateway refuses mail to a contact on a poller turn anyway. Mail **forwarded by the principal** — the sender resolves to the principal and `_new_text_lines` reports a forwarded message — is wrapped in a `` block: summarise it, draft a reply the principal could send (as text in the reply, never sent or saved as a Gmail draft), note any commitment or date, and offer to add the original sender as a contact (confirmed from the web app or the principal's Slack / Discord, since an ordinary email turn cannot change the People list — the one email that can is the principal's tokened reply to a roster-request email, which is parsed before any model turn; see below). The reply still goes to the principal only. The From header is unauthenticated, so this framing grants nothing: a spoofed \"forward\" just gets the real principal a summary.\n\n **An email that got no reply goes to Needs you.** Mail from the team or the principal is answered by the model calling the mailbox's send tool, so a turn can end with nothing sent (a tool refused, the answer only written). The gateway marks on the conversation who each accepted send reached (`Session.mail_sent_to`: Gmail `send_gmail_message` to/cc, Outlook `send-mail` recipients; `mail_replied_ids`: the message an Outlook `reply-mail-message` / `reply-all-mail-message` answered). After the turn the poller (`_note_reply_outcome`) checks the sender, by any of their addresses, or the message id; with no reply it files a card through `integrations.unanswered_mail` (source `unanswered_email`, high, tagged `private:principal` and routed to the principal, so only their own /today shows it), whatever the model did. Repeats in the thread fold into it (one card per thread, occurrence count), and a later turn that does reply resolves it. A reply the poller sees is also logged (`unanswered_mail.record_reply`, a private `integration_outbound` row keyed by thread), and the briefs read both: each INBOUND email from the roster says \"the Executive replied\" only on that evidence, or \"no reply went out\" while its card is open (`open_thread_keys`), so the brief stops telling the principal to answer mail the Executive already answered. Only mail Gmail proved is the principal's (`Session.email_authenticated`) reads \"Your email got no reply\" with its subject and what the Executive wrote, which never reached them; a teammate's card names who is waiting and never their subject or text (their conversation is theirs alone); mail that only claims a teammate's or the principal's address says it could not be proven and carries nothing the sender wrote. A plain-text `Error …` tool result is not counted as a send. Contacts and senders off the team are not answered by design and keep their own paths. Why: the brief read the inbound log and said people were waiting while Needs you, which shows alerts only, stayed empty, since an alert depended on the model choosing `create_alert`.\n\n **Other mail sources** (`integrations.mail_sources`). Mail read from anywhere but the Executive's own mailbox (a hosted inbound address, a relay) follows the same rules through a vendor-neutral hook. Nothing is registered by default and the hook has no settings: a deployment registers a `MailSource` (its loop, an `enabled` check, and how to replay a held message), the lifespan starts the enabled ones, and each message goes to `handle_inbound` as an `InboundMail`: the parsed message, the address it came to, `prove_sender` (DMARC, or SPF and DKIM aligned, as the source can tell), `send_answer`, an optional attachment reader and what a held message keeps. `handle_inbound` runs the poller's own handling (`email_poller._handle_mail`): sender resolution, private-to-principal rows, the teammate's own rows only when proven, untrusted-content wrapping, the principal's proven mail, the forwarded-by-principal framing, hydration and the turn deadline. Because a source mails the answer itself (with the AI line, and an `integration_outbound` row) rather than the model calling a send tool, three things differ: a stranger is held for the principal silently, with no acknowledgement and no turn, and replayed through the source that read it (`replay_held_mail` dispatches on the payload's `mail_source`); a contact's mail, and mail claiming a teammate that nothing proves, is answered to the principal and never to the sender; and roster-request and fact confirmations are answered only from the mailbox they were sent from. A source must be registered to be handled, it does nothing while the Executive is paused (`handle_inbound` returns `PAUSED` and the source keeps the message), a stranger the request queue won't take is logged as not held, and a sender who is neither team nor contact by the time the turn runs gets no answer at all.\n\nSlack, Discord, and Telegram are roster-gated on inbound: an unrostered sender's message never reaches the Executive (`details.outcome=\"rejected_unknown_sender\"`). The gates look up **team members only** — a contact's Slack, Discord or Telegram id is treated as unknown, so a contact can never talk to the bot (nor count as a co-present peer). What happens next is the roster request below — except for a contact, a thread continuation, a Telegram group or an unverified Telegram webhook, which stay silent. Email is not inbound-gated — non-roster mail flows through with a `[POLICY]` notice so triage can classify it. Slack/Discord thread continuations pass the shared `integrations.response_gate` (utility-fast model, biased toward replying). Slack only continues a thread it has replied in: it reads the thread with `conversations_replies` (5 s bound) and looks for its own user id, which `auth_test` resolves at startup. If that lookup fails, mentions and DMs still work and thread continuations stop until a retry succeeds (`slack_bot._resolve_bot_user_id`, at most once a minute, from the channel `message` listener). When Slack doesn't return the thread in time, the thread's stored history (`slack:thread:{channel}:{thread_ts}`, written on every turn the bot answers there) stands in: with history, a rostered sender gets an ephemeral \"I couldn't read this thread just now… @-mention me\" and the drop is audited (`outcome=dropped_thread_unreadable`). A thread the bot never answered in stays silent. A message the gate skips is audited (`outcome=skipped_gate`, with the gate's reason) and gets an :eyes: reaction (`reactions_add`, which needs the bot's `reactions:write` scope; without it the reaction is skipped), so a sender the gate misjudged can see the message was read and @-mention the bot.\n\n**No link previews on replies.** The Executive's replies go out with previews off — Telegram `sendMessage` carries `link_preview_options.is_disabled`, Slack's reply `say` passes `unfurl_links=False` / `unfurl_media=False`, and every Discord reply send passes `suppress_embeds=True`. The direct messages the Executive starts are the same: `send_slack_dm` posts with `unfurl_links=False` / `unfurl_media=False`, and Discord's `send_dm` sets the `SUPPRESS_EMBEDS` flag on each chunk, so a reminder or a brief sent by DM carries no preview either. Otherwise the chat service fetches a link in the text on its own, so a reply that quotes someone's mail (Act as me reads it) could carry data to a sender's server with no click. The web chat does the same for images: `Message.tsx` loads only an image the app itself serves (`lib/markdownImages.loadsInline`) and shows any other as a link.\n\n### Alert triage of chat messages\n\nEvery chat-app message that passes the roster gate (Telegram, Slack, Discord, Google Chat) is also handed to alert triage through `alerts.pipeline.schedule_chat_message_evaluation`. The sender is named by their People record (`full_name`), not by the name they typed into the chat app, so a person whose account there is called something else is never read as a stranger; the chat app's own name (or raw user id) is used only when nobody on the roster matched. The principal's own messages to their Executive are a conversation, not news for their brief, so they are not triaged at all. Triage sees the sender as `sender:` and the room as `channel:`. Google Chat's inbound audit row also carries the matched People id (`sender_person_id`), looked up before the row is written, so the brief can tell the principal's own messages apart by id, never by a display name anyone can set.\n\n### New senders (roster requests)\n\nSomeone who writes in and is not on the People list — and is not one of the principal's contacts — is **held, acknowledged and asked about** instead of dropped (`integrations.roster_intake.intake` over the `people.roster_requests` ledger; lifecycle in Org). Each adapter calls it after its gate:\n\n- **Slack** — a DM or an `@mention` (not a thread continuation: the bot was not addressed there). The name and profile email come from `users_info` (5 s timeout). The acknowledgement goes in the DM, or as an **ephemeral** message for a mention, so nobody else in the channel learns who is and is not on the roster. The held event keeps only a fixed set of keys (text, user, channel, ts, thread, files).\n- **Discord** — a DM or a mention (not a thread the bot merely follows). The acknowledgement goes in the DM, or by DM for a channel mention; `/ask` answers the interaction with an ephemeral acknowledgement first (Discord allows 3 s), then holds the prompt. Its old \"ask the admin to add you\" reply is gone.\n- **Telegram** — only a **private** chat, and only when `TELEGRAM_WEBHOOK_SECRET` is set and valid: Telegram identifies the chat rather than the sender, and without the secret anyone could forge an update. A group, or an unverified webhook, stays silent as before. Attachment file ids are held and downloaded only on replay.\n- **Email** — non-roster mail that is not private to the principal (so never a contact's) and does not look machine-sent (`looks_automated`: a no-reply / notifications / billing-style local part, a `List-Id` / `List-Unsubscribe` / `Auto-Submitted` / bulk `Precedence` header, or a DMARC failure). The mail is **still triaged** by the Executive on this pass; its `[POLICY]` notice now says the sender was already told their message is waiting and the principal was asked who they are, so the model raises no second alert just to add them, and that the email will come back once they are added.\n\nEvery path skips a contact first (`find_person_by_*_id(..., include_contacts=True)`, or the email poller's private-mail rule): a contact has no chat access and gets no request and no acknowledgement, and the team-visible `rejected_unknown_sender` / `accepted_non_roster` rows read the same for a contact as for a stranger.\n\n**The acknowledgement** is one fixed text (`roster_intake.ACK_TEXT` — \"your message arrived … waiting for confirmation\"), never the model and never an echo of what they wrote, so a forged sender gains nothing by triggering it. It is sent at most once per sender per `ROSTER_ACK_WINDOW_DAYS` (7) and at most `ROSTER_ACK_DAILY_CAP` (20) a day across all senders (`claim_ack`, one transaction; a failed send releases its claim). An email acknowledgement is the one send the MCP gateway lets reach an address off the roster: `send_email_ack` sets `mcp_gateway.roster_ack_grant`, a one-shot ContextVar grant that admits a single `send_gmail_message` with exactly the granted recipient (a plain string, no list), subject and body and no other key, then is spent (audited `integration_outbound`, `kind: roster_ack`, private). An email sender is acknowledged only when Gmail authenticated them: once `claim_ack` succeeds, the poller's `send_ack` re-reads the mail as raw MIME and needs Gmail's own topmost `Authentication-Results` reporting `dmarc=pass` for the From domain (`fact_confirmation.sender_authenticated`, fail-closed). The printed headers the poller reads never carry that header, so without the second read a forged From would draw the acknowledgement to whoever it names — backscatter from the Executive's mailbox. An unauthenticated sender makes `send_ack` raise `roster_intake.AckWithheld`: the claim is released, and they are held silently. `authenticated_by_gmail` strips quoted strings and comments before reading the verdict — Gmail copies the envelope sender and DKIM tags into its own stamp, and a quoted local part could otherwise carry a fake `dmarc=pass` — and needs exactly one `dmarc=` verdict reading exactly `dmarc=pass header.from=` (Gmail may follow it with its own `dara=` resinfo, so its position is not checked). Residual: when Gmail writes no verdict at all (a From domain without DMARC) and echoes an unsanitised `;dmarc=pass header.from=…` elsewhere in its stamp, that echo would read as the verdict; bounded by the acknowledgement caps. A withheld acknowledgement is audited `roster_ack_withheld`. `intake` acknowledges before surfacing the card and telling the principal, and both follow the request's `ack_sent_at`: a withheld, failed or capped acknowledgement reads \"haven't been told anything yet\" on the Today card, in the principal's prompt and in the email `[POLICY]` notice.\n\n**Telling the principal.** A new request puts a private card on the principal's `/today` (see Today) and pushes a prompt on their delivery plan — preferred chat DM, then the others, then email — for a chat sender or an email sender on the company's own domain; an outside email sender waits on the card, as does everything past `ROSTER_REQUEST_DAILY_CAP` (30) pushes a day (the same cap bounds how many requests open a day; a declined sender is not raised again for 30 days). A chat prompt can be answered in place (\"that's Annamarie, add her to the team\"). An email prompt carries a one-time token (`RR-` + 20 base32 characters, stored hashed); the poller checks every inbound mail for one **before** triage (`try_email_roster_answer`): the reply must come from the principal's primary address exactly (not an alias, not the company-domain rule), carry the token of a pending request, not be an automatic reply, and not fail DMARC where the header is visible; and is parsed by `parse_decision`'s `roster_identity` shape — anything else falls through to the ordinary path. The token is hidden (`RR-[hidden]`) in every other Google Workspace result the gateway returns — the confirmation sits in the Executive's Sent folder, and any turn could otherwise read it — except the poller's own fetch of an inbound message (`mcp_gateway.reveal_roster_tokens`); a token-bearing mail that is not an answer is scrubbed before triage.\n\n**Replay.** Once the principal answers (web card, chat tool, email reply) — or a People-page / `upsert_person` write makes the sender match someone (`after_roster_write`) — the held messages are replayed through the adapter that received them (`register_replayer`: Slack re-runs the held event, Discord and Telegram their held text, email re-fetches the message from Gmail and handles it as mail from a known sender), oldest first, now attributed to the new Person. A teammate gets every held message; a new contact only held email (it takes the private contact path; held chat is dropped); a decline drops everything. Replays run in a **fresh `contextvars.Context`**: the answer may have come from the principal's own verified turn, and a stranger's message must never be read with the principal's session — and their contacts — still bound.\n\n### Standing facts by email (confirm by reply)\n\nThe principal can ask for a standing fact, a retirement or a company-profile edit by email, and the fact tools check it exactly as in chat (the own-words gates run on `_email_memory_text`, the sender's new text, not the quoted chain or attachments). An email's From line proves nothing, though, and a standing fact is read by every later prompt as the principal's own account. So an emailed change is **held, never applied**, until the principal's own mailbox confirms it (`integrations.fact_confirmation`, `memory.facts.fact_confirmations`):\n\n- **Who may ask.** The poller stamps every email turn with `Session.email_from` (the bare lowercased From address) and `email_authenticated`. The text the poller reads never carries `Authentication-Results` (workspace-mcp prints a fixed set of headers), so for mail claiming the principal's primary address it reads the message again as raw MIME (`fact_confirmation.sender_authenticated`) and requires Gmail's own stamp: the **topmost** `Authentication-Results` header (Gmail adds it on receipt, above anything the sender wrote), authserv-id `mx.google.com`, reporting `dmarc=pass` for the From address's domain, with the raw From matching (`authenticated_by_gmail`). It fails closed: no header, `dmarc=none`, a temporary error or a failed read all leave the turn unauthenticated. Mail from a teammate other than the principal is read the same way, before its inbound audit row is written: authenticated, the whole handling of it is theirs alone in the audit log (`rows_for_person`, see Audit) and the turn reads their own mail as them (`Session.email_sender_authenticated`); unauthenticated, it keeps the ordinary rule. Mail from anyone else costs no second read and is never authenticated. `fact_tools._gate` admits an email turn only from the principal's **primary** address, exactly (no alias, no company-domain rule), authenticated, and not private to the principal (mail they forwarded stays refused, as do contacts, teammates and unknown senders).\n- **The hold.** The tool returns `awaiting_confirmation` (no action chip, and the model is told to say nothing has changed). `request_confirmation` stores the change with the SHA-256 of a fresh one-time token (`FC-` + 20 base32 characters, 100 bits; 7-day expiry; at most 10 waiting at once) and emails the token to the principal's roster address, outside any turn's outbound context. A profile edit is dry-run first, so one that cannot apply is reported in the turn, before any email goes out. If the email fails, nothing is held.\n- **Keeping the token from any model.** The tool result never carries it, and `mcp_gateway.hide_roster_tokens` now scrubs `FC-` tokens as well as `RR-` ones (`FC-[hidden]`) from every Google Workspace result except the poller's own fetch of an inbound message. Act as me's reads of the principal's own Gmail, where the confirmation email lands, hide them too (`delegation.gmail.parse_message` and the thread search), so the drafting model never sees a live token either.\n- **The reply.** `_handle_one_email` hands every message to `try_email_fact_confirmation` right after the roster-answer check, before any model turn. It acts only on a reply from the principal's primary address carrying the token of a **pending** confirmation. A made-up, spent, cancelled or expired reference matches nothing and falls through to the ordinary path with the token hidden, so no old or forged token can make the Executive email the principal. A reply with a live token is read again as raw MIME (`read_raw`) and must pass the same Gmail DMARC check as the request; one that doesn't (or whose raw read fails) is refused, the change keeps waiting, and the principal is told. Then an **automatic reply** is consumed quietly — no answer, no model turn, the change keeps waiting: the printed headers catch a Gmail vacation reply (`Precedence: bulk`), and the raw headers catch what they don't show (`automatic_reply`: `Auto-Submitted` other than `no`, `X-Auto-Response-Suppress` and similar, `Precedence: auto_reply`, an out-of-office subject), since an Exchange out-of-office echoes the token from the principal's own address and may well say \"I'll confirm on my return\". The raw message starts at the tool's exact separator line (`\\n\\n--- RAW MIME ---\\n`), so a sender-written Subject imitating it cannot supply the headers. CONFIRM (or yes / approve) applies the change through `fact_tools.apply_confirmed`, the same apply step chat uses. CANCEL (or no) drops it, and an unclear reply leaves it waiting. The principal gets a one-line result either way. A compare-and-set on the row means two replies can apply it only once.\n- **Audit.** Private `fact_confirmation` rows record held / applied / cancelled / refused. The applied change also writes the fact tools' usual private rows, with `source_channel=\"email\"` as its provenance.\n\n### Checking a channel is set up\n\nSettings → Setup status (`GET /setup/status`, `api/setup_checks.py`) gives each channel a light and a plain-language fix. It tests the credentials with one free read-only call — Slack `auth.test`; Telegram `getMe`, then `getWebhookInfo` (is a webhook registered, is it this app's `/webhook/telegram`, did a delivery fail in the past day); Discord `users/@me`, only while the bot is still connecting — and reads what the lifespan left on `app.state`: the Slack socket's `is_connected()`, the Discord bot's `is_ready()` together with its gateway socket being open (discord.py stays ready while it reconnects after a drop), or the exception its task stopped with (`LoginFailure`, `PrivilegedIntentsRequired`), and the MCP gateway. A listening Slack is amber while the listener hasn't resolved its own user id (`slack_bot.bot_user_id()`), since thread continuations are off until it does. Because inbound is roster-gated, a connected Slack, Discord or Telegram is amber while nobody on the roster has its id (`slack_user_id`, `discord_user_id`, `telegram_chat_id`), or when its latest `integration_inbound` row is a `Rejected: …` from the past day whose sender still isn't on the roster — the page shows the sender's id, never message text. Local login with no `TELEGRAM_WEBHOOK_SECRET` is red for Telegram, since the local-login gate turns its webhook away. Google Chat and Gmail are checked from configuration: a numeric project number and a readable service-account key; the OAuth client (or service-account key) the workspace-mcp child signs in with. Gmail is also amber when startup priming could not find every pinned Google tool (`MCPGateway.prime_pinned_tools`, whose result the gateway keeps on `pinned_tools`): the row names a few of the missing tools and counts the rest, and points at the `MCPGateway` log lines. It is amber too while that probe has not finished, and when the probe itself failed — the one check that reads green is the one that ran and found everything, so a crashed probe is never silently fine. The row speaks for the whole `google_workspace` server, so a missing Docs or Drive tool shows up under Email (Gmail).\n\nA channel setting written `KEY= # note` in a `.env` file arrives as the note's text, because python-dotenv strips only a comment that follows a value. `Settings` reads such a value as unset for the Slack, Telegram, Discord and Google Chat credentials, so the line no longer switches a channel on with its own comment as the token. `TELEGRAM_WEBHOOK_SECRET` is the exception, because read as unset a junk secret would turn the webhook's check off. It is kept instead, and since Telegram can only ever send 1–256 of `A-Z a-z 0-9 _ -` (what `setWebhook` accepts), a secret outside that set — a leftover comment, a pasted `` — could only be matched by someone who guessed it: while it is set the webhook refuses every update, neither the local-login gate nor the roster tools count it as proof (`Settings.telegram_webhook_secret_valid`), and Setup status shows it red (`.env.example` shipped four such lines; the notes now sit on their own lines).\n\n### Picking people and rooms by name (chat directory)\n\nNobody has to copy a raw Slack, Discord or Telegram id. The person page, Add person and a department's Channels settings pick them by name from what the bots this instance runs can reach (`integrations/chat_directory.py`, served at `GET /chat-directory/people`, `/channels` and `/slack/lookup`, the principal's alone). The id is still what is saved; the by-hand field stays under \"Enter an ID by hand\", and is all there is for a provider with no bot.\n\n- **Slack** — channels from `conversations.list` (public, plus the private ones the bot is in; scopes `channels:read`, `groups:read`, and public only when `groups:read` is missing) and people from `users.list` (no bots, no deactivated accounts; `users:read`, and `users:read.email` for their profile email). A person with a work email is matched by it (`users.lookupByEmail`, after the cached list): the picker pre-selects \"Found by email\" with a \"Not them?\".\n- **Discord** — text and announcement channels of the servers the bot is in (only `DISCORD_GUILD_IDS` when set) and their members over the REST API. Members need the Server Members privileged intent; when Discord refuses, the list is empty with `members_available` false, not an error.\n- **Telegram** — there is no list API, so the webhook records each chat an update comes from in `telegram_chats` (`integrations/telegram_chats.py`): private chats are people, groups and supergroups are rooms. Only a webhook whose `TELEGRAM_WEBHOOK_SECRET` checked out records anything, since without it anyone could post a made-up name for a chat id. A row is a suggestion only and grants nothing: the roster still decides who is answered.\n\nA department's Slack and Discord pickers pre-select the room named most like the department; it is saved only when the person presses Save. Lists are cached in-process for 60 s and capped at 2,000; a provider that fails reads as an empty list. Tokens go only in the Authorization header to the provider's own API host and are never returned.\n\n### Conversation state\n\nEvery adapter keys a deterministic `session_id` off the conversation surface and replays that session's stored turns into `Session.conversation_history`, so a follow-up (\"yes, go ahead\") lands with the question still in context. Slack's ids are `slack:dm:{user}`, `slack:thread:{channel}:{thread_ts}`, and `slack:channel:{channel}:{user}` for a mention that starts a thread; because the bot's reply roots a Slack thread at that message's `ts`, the turn is double-written to that thread's session too, so the first continuation inside it is not cold. A reply inside a thread THAT PERSON ROOTED also offers the parent `slack:channel:{channel}:{user}` id when matching an approval gate, since a gate raised on the mention recorded that session while the answer arrives under the thread's. The root-author check is load-bearing: widening on \"threaded and not a DM\" alone offered one person's channel session inside any thread in that channel, so a remark they made to a colleague could resolve their open gate and post the run's title into that colleague's thread. Discord has the same shape — its auto-thread router promotes a mention reply into a bot-owned thread — and the same alias. An @-mention inside a DM is handled once, by the DM path: Slack fires both events, and taking both forked the conversation across two session ids. The same id is the audit grouping key and the scope passed to `format_for_prompt`. A per-session `asyncio.Lock` serializes turns within one conversation — the inflight semaphore caps fan-out across the workspace, which does nothing to stop two messages in the same thread from both reading history before either writes it. Persistence happens after the reply is sent and its failure is logged, never surfaced: the user already has their answer.\n\n**Conversations within a chat app.** A Telegram chat, a Slack or Discord DM and a person's @mentions in one channel (`slack:channel:…`, `discord:channel:…`) are each one endless stream, so they are cut into conversations (`memory.conversations`, shared helpers in `integrations/conversations.py`). Each adapter computes the stream's base id as before and asks `conversation_for` which conversation a message belongs to: the newest, unless the stream has been quiet for two hours (`CONVERSATION_GAP`), when the next one starts. The first conversation keeps the bare base id; later ones add `~2`, `~3`, … (`memory.conversation_ids`). A whole message of `/new`, \"new chat\" or \"new conversation\" starts a fresh one instead of a turn and is answered with the name of the one it ended; the new row has no messages and stays off the Chats page until its first turn. After a conversation's first exchange `title_after_turn` names it in the background with `utils.chat_topics.name_chat` (the first words of the message if that fails), as a web chat's first turn does: the same call files it under one of its owner's chat topics, so a Telegram or Slack conversation sits beside the web chats about the same matter in the Chats page's Topic view. Threads (`slack:thread:…`, `discord:thread:…`), email and every other id are left whole. What has to see the whole stream compares base ids: the inbound resolver, so an approval asked before a cut is answered after it, and the session-scoped decisions and advice `format_for_prompt` renders. At boot `split_existing_streams` cuts history saved before this by the same rule, once (`app_migrations`), titling each part from its first message and carrying a read-mail lock to every part. An adapter installed outside the package adds its streams with `conversation_ids.register_rolling` and cuts its own old history once, with its own migration name and an `only` filter for its base ids, because the built-in sweep may already have run without it.\n\nThis was absent on Slack until #136. Every message built a fresh `Session` and minted a per-*message* id (`thread_ts` falls back to the message's own `ts`, so a DM never repeated one), which meant a multi-turn confirmation could not work: the Executive asked whether to send a briefing to Slack, the user said yes, and the next turn had no record of the question.\n\n### What the model is told about the channel\n\nChat adapters pass a `` block (`integrations.channel_context`) in the **user turn** — never a cached system block. It names the surface, states that the person cannot see the app's cards or buttons (except where a chat shows them, below), and draws the line the assistant kept crossing: briefing proposals and alerts are actioned on the briefing page; a proposal that books something can only be approved there; a workflow sign-off the Executive is explicitly waiting on *can* be answered in the conversation.\n\n**Approval cards with buttons (Telegram).** In a private Telegram chat on a webhook only Telegram can call (`TELEGRAM_WEBHOOK_SECRET` set and valid; `integrations.channel_cards.shows_cards`), the cards a reply leaves are sent as they are shown under a web chat reply: a meeting to book and a card of actions from `propose_actions`. `Executive.chat(cards=...)` collects their ids from the turn's `action_taken` events; the adapter sends each as a plain-text message with **Approve** and **Skip** inline buttons (`callback_data` `card::a|s`), and the `` block tells the model the card is there and that a typed yes does not approve it. A tap arrives as a `callback_query` and is taken only from a private chat whose sender is that chat (`from.id` = chat id), on the verified webhook; the chat's rostered person is the tapper. `api.routes.decisions.resolve_in_chat` applies the web chat's rule (a card of actions is its person's alone; a meeting is the principal's, or its approver's unless it is the principal's private booking), books or carries out the card exactly as stored (`delegation.verified.ChatTap` stands in for the signed-in caller on an action card), or records the skip. The card's message is then edited to show the outcome without buttons. Elsewhere (Slack, Discord, Google Chat, a group, an unverified webhook) no card is sent and `propose_actions` tells the model the card is on Today. The principal's DMs on Slack, Discord and Telegram also receive the open-alert digest (`briefing.context.format_open_alerts_for_prompt`) as the `` block, gated to DMs because the board is company-wide and a shared channel must not pull it in. That digest is the ONLY trusted source of an `alert_id` for `ack_alert`. The briefing page's `[Discuss mode — alert_id=N]` primer is a pointer to which item is under discussion, not authority on its own: the server accepts that id only if it is also on the live board it derived itself. An id appearing anywhere else, including inside an alert's own body, is not trusted: alerts are minted from inbound email and chat, so their bodies are attacker-controlled. Two mechanisms enforce that rather than one prompt rule. Every field the digest interpolates is collapsed to a single line, because a newline in an attacker-written headline, suggested action, tag or review note would otherwise forge a whole extra `[N]` line — a fabricated instruction to clear somebody else's alert. And the live board the server derived is recorded on the session, so `ack_alert` refuses any id outside it — on EVERY session, web included; the web previously had no check at all. The recorded set is the whole live board (the cap `/today` renders as cards), not just the rows the digest printed, so a card past the digest's 30-row render cap is still ackable rather than sending the principal back to the page. Be precise about what this buys: it stops an id that is invented, closed, snoozed or expired, including one quoted out of an attacker-written alert body. It does NOT stop the model being argued into acking the wrong LIVE card — one alert's body can still make a case about another id that is genuinely open. Scoping trust to the single item under discussion would need the primer id carried server-side, which it is not.\n\n### Handler failures\n\nAn adapter's catch-all replies \"I encountered an error processing your request.\" On Slack that used to be the whole story — one uncorrelated log line, no audit row, and an unguarded error-send that could itself escape into Bolt. It now logs with the session, sender, channel, thread and mode, and writes an `integration_inbound` row with `details.outcome=\"handler_error\"`, joining the inbound row under the same `session_id`. Upstream, a single crashing tool handler no longer takes the turn down at all: the orchestrator's tool gathers use `return_exceptions=True`, hand the model an error `tool_result`, and audit it with `ok=false` (cancellation is re-raised, not swallowed).\n\n### Attachments\n\n`integrations.attachments` handles web chat, Discord, Telegram, Slack and Google Chat. Images become Anthropic vision blocks; documents (PDF/DOCX/XLSX/TXT/MD/CSV) are inlined into the turn that carried them — inside an `\">` block (`content_trust.wrap_untrusted`, whose closing tag the text cannot forge), under an `[Attached: ]` label, which the open-loop pass reads as \"not the speaker's words\" and the decision extractor strips before it reads the turn — and indexed into the ChromaDB `inbound_attachments` collection. 20 MB/file cap; web chat uses `POST /chat/upload`.\n\n**Voice notes** are transcribed before the turn, since the model takes no audio. `providers.transcription` calls any OpenAI-compatible `POST {base}/audio/transcriptions` server: `TRANSCRIPTION_BASE_URL` (with `TRANSCRIPTION_API_KEY` / `TRANSCRIPTION_MODEL`) wins, otherwise OpenRouter when `OPENROUTER_API_KEY` is set (`elevenlabs/scribe-v2`), otherwise there is none and a voice note becomes a line asking the sender to type it. Recordings are capped at 25 MB and `TRANSCRIPTION_MAX_SECONDS` (300), the length read on the server from the Ogg, WebM, MP4 or WAV container (`recording_seconds`), since the servers bill by length and the browser's own stop can be skipped; a file whose length can't be read (mp3, flac, aac) is held to 16 KB per allowed second. A file can misstate its own length, so the bound that holds is bytes: at most `TRANSCRIPTION_MAX_MB_PER_DAY` (200) go to the speech server per UTC day, failed calls included. A voice note the sender recorded in the chat app (a Telegram voice or video note, a Discord voice message, a Slack clip; `AttachmentItem.voice_note`) becomes their own words (`[Voice note, transcribed]`; those senders are roster-gated). The reply is the answer alone, with no echo of what was heard. Any other audio (a Telegram audio file, an uploaded recording, anything on Google Chat) may hold someone else's voice, so its words are attachment text, untrusted-wrapped like a document's. An email's audio attachments (up to 2) are transcribed into attachment text, untrusted-wrapped like a document's. Each call writes one usage row (actor `transcription`, with the cost the server reports) and tells any `add_usage_listener` listener. The web chat mic records in the browser and posts to `POST /chat/transcribe`; the words go into the message box to read and send.\n\n**Scanned PDFs** are read, not reported empty. A PDF with no text layer goes through `knowledge.pdf_reader`: with `PDF_PROVIDER_READING=true` (opt-in, off by default) the deployment's own model reads it through its provider's PDF support (Anthropic natively, OpenRouter's `file-parser`, a local server only with `LOCAL_PDF_INPUT`); otherwise, and as the fallback, local OCR (pypdfium2 + RapidOCR, offline, no key) — so every deployment converts them. The label then reads `(converted from scanned pages)`, and an unreadable one says why. The index takes the text already extracted for the turn, so a scan is converted once. A file a channel delivered (everything here except `POST /chat/upload`, where the signed-in user sends it) converts at most `PDF_INBOUND_MAX_PAGES` (30) pages and draws on a rolling `PDF_INBOUND_PAGES_PER_HOUR` (300) budget shared by all senders, so sending scans cannot run up unbounded model spend or CPU.\n\nPer channel:\n- **Slack** — a `file_share` message (previously dropped outright for its `subtype`) is handled like any message; files download only after the roster gate and the response gate, with the bot token sent only to `https://files.slack.com/` (needs the `files:read` scope). History and peer memory record the words plus `(Attached files: …)`, never a document's text.\n- **Google Chat** — uploaded attachments download through `media.download` with the reply credentials (`chat.bot`), checked against the 20 MB cap per chunk, 5 per message; an attachment-only message is no longer dropped. The webhook authenticates Google, not the sender, so files are read only when the event's `user.email` is on the People roster (a team member); anyone else's files are named to the Executive, never downloaded. A Drive file shared into a message is left to the Drive tools, with its id.\n- **Email** — the poller (`integrations.email_attachments`) downloads up to 5 readable attachments of the message itself via `get_gmail_attachment_content` — workspace-mcp in stdio mode saves the file and answers `Saved to: `, which must start its line, be the last such line and end in the answer's `Saved filename:` (the answer echoes the sender-chosen attachment name first, and a name carrying `Saved to: /…` must not redirect the read), then resolve inside the tool download folders — and appends their text to the turn (15k chars each). Image attachments (PNG/JPEG/GIF/WebP) go the same way into vision blocks for the turn: up to 3, none under 10 KB (signature logos, tracking pixels) and none over 3 MB (the model API's 5 MB base64 limit). A registered mail source's `read_attachments` may return images too (`EmailAttachments`). Only for a sender the principal knows (team, contact, principal); a stranger's files stay a list. It runs outside the model loop, so it works on private turns without adding the attachment tool to `PRIVATE_TURN_MCP_TOOLS`.\n- Anything else (a Drive PDF, a skipped attachment) — the Executive's `read_document` tool.\n\nIndexed chunks are keyed on `attachment:` and tagged `type=attachment`. An attachment's name is chosen by whoever sent the message and the name is the chunk-id namespace, so without the prefix an inbound `strategy-2026.md` would upsert over the curated company document of that name. The prefix separates inbound content from curated uploads and keeps provenance visible; it does not separate senders from one another.\n\nIsolation is the **collection**: `retriever.retrieve` never queries `inbound_attachments` at all, so an attachment cannot resurface as company knowledge in a later, unrelated turn. Attachment processing is roster-gated on Discord too — it previously ran before the gate, so an unrostered sender's file was downloaded and indexed before their message was dropped. These rows still have no operator surface: they are never written to `company/docs/`, so `GET /documents` does not list them.\n\nThis replaced a domain-based scheme that did not work. The chunks lived in `company_docs` under a domain outside the specialist set, on the premise that an unrecognised domain matched no filter. It matched every one — `ChromaDBStore.query` attaches a `where` clause only when a domain filter is present, and an Executive-level retrieval passes none — so attachments came back under “From your company documents”, top-ranked and indistinguishable from a curated upload. Legacy rows are migrated across on boot.\n\n### MCP gateway & outbound\n\nMCP runs in **both directions**. As a *client*, `orchestrator.mcp_gateway.MCPGateway` proxies MCP servers (Gmail, Calendar, Drive, Outlook mail and calendar — whatever `mcp_servers.json` configures) as Executive tools, with egress gating on Gmail recipients, Calendar attendees, Drive share grantees, and Microsoft 365 recipients/attendees against the roster (and a refusal of public / whole-domain Drive sharing). The Gmail gate also accepts only a fixed set of argument keys, so workspace-mcp's `reply_all` (recipients taken from the thread, never checked) and `forward_message_id` / `include_forwarded_attachments` (forwarding any inbox message) are refused; a reply is `thread_id` plus an explicit, roster-checked `to`. Three more refusals close paths around those gates: every Apps Script tool (code running as the Executive's account, outside the gates, and since workspace-mcp 1.29.0 schedulable to keep running); any argument that makes the server fetch a URL (`fileUrl` / `file_url` on the Drive and import tools, a Gmail attachment given as a `url`), since the fetch can carry data out in its query string before any recipient gate matters; and text Google would mail to someone the roster never checked (`rsvp_comment` to an event's organizer, an out-of-office `decline_message` to whoever invites the Executive). Every gate reads its arguments by normalized spelling: workspace-mcp 1.29.0 renames a camelCase key such as `Attendees`, `userGoogleEmail` or `rsvpComment` to the real parameter, so a gate matching the exact key would never see it. Arguments that are not a JSON object are refused for the same reason. Google-side fetches are refused too: any key ending in `url` / `uri` at any depth whose value has a URL scheme (a Slides image or page background, a Docs or Forms image; a document hyperlink and a meeting link are stored, not fetched, and pass, as does a Drive file id), (a value is a URL after the characters a URL parser strips are removed, and `//host` counts), and on every tool that can write cells — the Sheets tools and the Drive create / update / import tools, which turn CSV into a Sheet — any string anywhere holding a formula call that fetches (`IMPORTDATA`, `IMPORTXML`, `IMPORTHTML`, `IMPORTFEED`, `IMAGE`), JSON-encoded or mid-CSV included: a string argument that encodes JSON is read as the server decodes it (one too large to read is refused), text is also scanned with character references decoded and comments / CDATA removed, and an upload that would become a native Sheet may only be text `content` — a `base64_content` or server-side `file_path` upload there is refused, since an XLSX hides its formulas behind zip members and XML encodings; a PNG to Drive or a DOCX to Docs is untouched. Not covered, unverified: whether Drive's HTML / Markdown import fetches an `` when it converts `content` into a Doc. A `manage_event` call that names no attendees is sent with `send_updates=\"none\"`, so the event's existing guests are not emailed text the gate never checked. A delete is sent the same way, except the typed `cancel_calendar_event` of a meeting the Executive booked, which keeps its cancellation notice. Google Docs, Sheets, Slides, Forms and Drive writes run only on files and folders the Executive's own Google account owns (`_check_google_file_write`, read through `get_drive_file_permissions` at call time, fail-closed; only a name starting with a read verb skips it), as OneDrive writes do. Someone added to People in the current turn is left out of the allow-list until the next turn. The allow-list (`_egress_sets`, built by `_roster_allow_set` as a `people.identity.RosterAllow`) is every team member's email and email aliases plus the Executive's own address, always, and — for a teammate — any address on one of the company's own domains whose local part (`+tag` dropped) matches theirs, the same rule inbound mail is matched by; a **contact's** email joins it only when `people_tools.contacts_reachable_now()` is true — a turn the principal started on a verified surface (web chat, their own Slack or Discord, a verified private Telegram chat), or inside `grant_contact_egress()` (the decisions approve route, when the approver is the principal). On any other turn — an inbound email, a teammate, the scheduler, a workflow, the alert review — a contact's address is refused exactly like a stranger's, so the refusal cannot reveal that it belongs to a contact (contacts are private to the principal). The single exception to the roster rule is the fixed roster-request acknowledgement above, under its one-shot `roster_ack_grant`. A turn about the principal's private mail (`Session.private_to_principal`) narrows the list to the principal's own address and the Executive's. Such a turn may call only the MCP tools in `schedule_tools.PRIVATE_TURN_MCP_TOOLS`: Google Workspace reads (`search_gmail_messages`, `get_gmail_message_content`, `get_events`, `list_calendars`, `query_freebusy`, and the Drive, Docs and Sheets reads such as `search_drive_files`, `get_drive_file_content`, `get_doc_content` and `read_sheet_values`) and the two Gmail tools this gate narrows to the principal (`send_gmail_message`, `draft_gmail_message`). It is an exact allow-list, not a server: workspace-mcp runs at the tier `WORKSPACE_MCP_TOOL_TIER` names, `complete` by default, where a Google Chat message to a space, a Docs, Sheets or Drive write into a file already shared with others, `manage_event` on a shared calendar (its gate checks attendees and pins notifications, nothing else) reaches people with no recipient check; and every other server has none at all (a Slack or Notion server, the default `fetch` server, whose URL can carry the turn's content, a server loaded at runtime). Its `search_tools` results show only the allowed tools (`workflows.tool_catalog.filter_search_results`; output it cannot parse reads as no match), and a `call_tool` naming anything else is refused with an error tool_result and an audit row private to the principal (`schedule_tools.private_turn_withholds`), a missing name included. `search_tools` and `call_tool` stay offered, so the tool list and its cached prefix do not change. The typed calendar tool (`_resolve_attendees`) and the chat DM tools (`message_person`, `send_telegram_message`, `send_discord_dm` and their person-id recovery) apply the same rule — a contact's id reads as an unknown one off the principal's turn — and a private turn may DM or invite only the principal (`PRIVATE_TURN_REFUSAL` otherwise); `message_person` never falls back to a \"Needs you\" alert for a contact, who cannot sign in to see it. The Google Workspace server (`workspace-mcp`) behind those Gmail/Calendar/Drive tools runs **co-located inside the API** as a stdio child of the gateway — fitting the single-tenant model (one install = one company = one exec Google account), so there's no separate service to operate. It's baked into the API image and launched by `docker/workspace-mcp-launch.sh`, which selects single-user OAuth (token persisted at `/data/google_credentials`) or service-account domain-wide delegation from `GWORKSPACE_AUTH_MODE`. The optional `WORKSPACE_MCP_TOOLS` (comma-separated service names, default `all`) limits which Google services it loads. The gateway forwards the Google secrets to the child via `_FORWARDED_ENV_VARS`. Every `google_workspace__*` call acts as the Executive's own account. workspace-mcp takes the acting account per call (`user_google_email`) and nothing used to check it, so under domain-wide delegation a model-chosen address could open anyone's mailbox in the domain; `mcp_gateway._check_acting_account` now refuses a value naming any other address — before every other gate, with an `integration_outbound_blocked` audit row. An absent value stays allowed (the server's own account).\n\n**An extension's own server.** An installed extension may register a check for an MCP server it brings (`extensions.register_gateway_check`, tools `__*`). `call_tool` asks it after the gateway's own refusals (Act as me's lockdown and outside content, a turn from another AI app, saved files) and before any per-tool gate. It gets the normalized tool name and a copy of the model's arguments, which must be a JSON object. It may refuse, and the refusal is audited as `integration_outbound_blocked` (`field: server_check`). Otherwise it may give `outgoing`, which builds the arguments actually sent: after every gate, from the model's own arguments, which stay what is recorded, and again for each send, so a discovery retry gets its own (a token good for one request). A check or `outgoing` that raises or returns the wrong type refuses the call, so a broken extension fails closed. The proxy routes by the exact name, so a call whose name only normalizes to a checked server (`Kiosk__x`, ` kiosk__x`) is refused rather than signed, and `load_mcp_server` refuses a runtime server whose name normalizes to one. A string of eight or more characters that only `outgoing` added (a signed token) is replaced by `[redacted]` in the answer, should the server quote it back. If the extension then fails to load, its servers stay refused, never unchecked: those it registered a check for, and those it names in a module-level `GATEWAY_SERVERS`. The built-in servers take no check.\n\n**What the model is told is connected.** `MCPGateway.start()` records the configured server names (`server_names`), and the Executive renders them with the channel settings into block 0's *Connected Systems* section (`prompts.connected_systems`; see the caching section). While Google is connected that section pins the Google tool names the Executive uses constantly (`GOOGLE_TOOL_MANIFEST`, narrowed by `connected_systems.google_tool_manifest` to the services and tier actually loaded) and tells it to call them with `call_tool` directly, with no `search_tools` round trip. The narrowing is the point: a name pinned for a service workspace-mcp never imported is not in the index, so the model calls it, receives extensible-mcp's “has not been discovered via search_tools” refusal, and has no way to square that with its own instructions. extensible-mcp only runs a tool one of the session's searches has returned, and remembers it for the session's life, so the gateway does that discovery itself: the lifespan starts `MCPGateway.prime_pinned_tools()` in the background once the gateway is up with `google_workspace` configured (an exact-name search per pinned tool, the way `workflows.tool_catalog.resolve` does it, over the same narrowed set the prompt advertises — `connected_systems.pinned_google_tools`, so priming never probes a service that was not loaded). A name neither the prompt nor priming can account for is logged once, naming both causes: excluded by `WORKSPACE_MCP_TOOLS` / `WORKSPACE_MCP_TOOL_TIER`, or renamed by workspace-mcp since the pin. A pinned call that still comes back with extensible-mcp's \"has not been discovered via search_tools\" refusal, for example before priming finished, is discovered and retried once. That happens after every gate has passed the same arguments, and only for pinned names: any other tool still needs the model's own search, and nothing here widens what a call may do, including the private-turn allow-list.\n\n**Microsoft 365** is the Outlook twin: `@softeria/ms-365-mcp-server` (a Node package, pinned) is installed in a throwaway Node build stage and only the `node` binary plus the package are copied into the API image, launched by `docker/ms365-mcp-launch.sh` as the `microsoft_365` entry with an **explicit tool allow-list** (`--enabled-tools`, an anchored regex) rather than the upstream `mail,calendar` preset — that preset also registers inbox forwarding rules, mailbox settings (external auto-reply), calendar sharing and event forwarding, which can address people the gateway never sees. Exactly the Outlook mail + calendar tools and the OneDrive tools are exposed (`microsoft_365__send-mail`, `microsoft_365__create-calendar-event`, `microsoft_365__search-onedrive-files`, …; hyphenated Graph endpoint aliases, proxied verbatim), and every admitted tool is either roster-gated or carries no recipient; the gate also covers the excluded writers in case an operator widens the list. Both servers can be configured at once. Auth is the MSAL device-code flow against the operator's own Entra app registration (`MS365_MCP_CLIENT_ID` is required; the server's built-in public client is refused); the refresh token persists at `/data/ms365_credentials` and is seeded once with `ms365-mcp-launch.sh --login` from inside the container — the server has a native device-code CLI, so there is no mint script and nothing to copy. The launcher scrubs the literal `$VAR` placeholders extensible-mcp leaves for unset variables (otherwise MSAL would be handed `$MS365_MCP_TENANT_ID` as an authority), and `MS365_MCP_EXPECTED_USERNAME` pins the bound mailbox. The egress gate for this family walks Graph's nested shapes (`body.Message.toRecipients[].emailAddress.address`, `body.attendees[]…`) — every email-shaped string outside the free-text fields must be on the roster, control characters and non-ASCII addresses are refused, unforeseen keys are still scanned (fail-closed) — and covers the whole write surface: send, reply, reply-all, forward, every draft tool, `update-mail-message` (it can PATCH recipients onto a draft) and create-/update-calendar-event; reads pass through. `delete-calendar-event` makes Exchange mail every attendee, so it reads the event and roster-checks the attendees first, except the typed `cancel_calendar_event` of that same event. Graph's reply, reply-all and send-draft actions carry no recipient in their arguments (the recipient is whoever the referenced `messageId` names), so for those the gate first reads the referenced message through the server and roster-checks the recipients Graph will derive (`replyTo` else `from` for a reply; `replyTo` and `from` together, plus the original to/cc, for reply-all; the draft's own to/cc/bcc for send-draft), refusing on any lookup failure; RSVP and cancel actions get the same treatment through an event lookup, because their comment is emailed to the organizer or every attendee; `download-bytes` is pinned to mail attachment bytes and OneDrive file content (`/drives/{id}/items/{id}/content`, optionally Graph's `?format=pdf` rendering), and a OneDrive result comes back as the file's extracted text rather than base64 — the attachment pin is what makes the poller's promise that an unrostered sender cannot be auto-replied to hold on Outlook as it does on Gmail; and the rendered inbound text quotes any forged `--- REPLY ---` line inside a body so the persona only ever reads the real block. Every mail write's `from`, `sender` and `replyTo` may name only the Executive's own address (the Outlook twin of the Gmail acting-account pin), so the model cannot send as a rostered colleague's mailbox or steer replies elsewhere; and `move-mail-message` into Deleted Items, Junk Email or Recoverable Items is refused, so a move cannot trash mail ungated. The server registers its account tools whatever its tool allow-list says, so the gateway itself refuses `login`, `logout`, `select-account` and `remove-account` (audited like any other refusal) and drops them from `search_tools` results — a chat turn cannot sign the mailbox out, switch accounts or start a sign-in; setup is only the launcher's `--login`, and the read-only `verify-login` / `list-accounts` stay callable. The example config's hyphenated patterns still deny `delete-mail-*`, `logout`, `remove-account` and `download-bytes-to-file` as defense in depth. A successful `send-mail` records the same outbound-context linkage Gmail does.\n\n**OneDrive in chat** (opt-in with `MS365_MCP_ONEDRIVE=true` and a fresh `--login`, since the server asks for its tools' permissions on every token refresh and a sign-in without file consent would otherwise fail mail and calendar too) matches what the Executive can do on Google Drive: search, list a folder, read a file, upload and edit, create folders, move, rename, copy and share; there is no delete tool. The launcher adds `Files.Read.All` at sign-in (`--extra-scopes`), so folders shared *with* the Executive can be read too; writes (upload, new folder, move, copy) land only in the Executive's own drives, checked against `list-drives` at call time, so a planted instruction cannot save company files into a stranger's shared folder. Sharing is gated like Drive sharing: `share-drive-item` invitees must be People on the roster and named by email (an `objectId` or `alias` grantee is refused, since nothing can check it), no `owner` role, and `create-drive-item-share-link` must be scoped to specific people (`scope: \"users\"`): an anonymous or whole-organization link, or one with no scope (the tenant default), is refused. An invite must require sign-in and grant only view or edit, a link must be a view or edit link, and `search-onedrive-files` takes plain words only, since its query goes into the request path unencoded. Files Act as me's `save_my_email_attachment` copied from someone's own mail into the Executive's Drive or OneDrive (`delegation.saved_files`) are that person's alone: on any other turn the gateway refuses a call whose arguments name a recorded file id and drops the entries or lines of a result that name one, failing closed for the file tools when the record can't be read. The folders those copies go in are made by the save (never adopted by name) and recorded, and a model call naming one, by id or by an OneDrive path through it, is refused on every turn, the owner's too, with their ids hidden from results. That tool's own calls are the only Drive and OneDrive writes a turn that read the owner's mail may make: built in code, admitted past the lockdown as those exact objects (`code_built_call`), and still checked by every gate above.\n\nAs a *server*, `openexecutive.mcp_server` exposes OE's own company context and specialist council to external MCP clients at `/mcp` — the inverse direction; see the dedicated **MCP Server** section. The gateway launches extensible-mcp as a `uvx` subprocess whose fastembed tool-search index uses an embedding model baked into the API image and loaded offline, so it no longer re-downloads that model from the Hugging Face Hub on every cold start. extensible-mcp is pinned twice over: to a commit, which fixes its own code, and with `--exclude-newer`, which fixes the resolution of its dependencies — without the cutoff uvx re-resolves them against PyPI on every networked start, so a new release of any of them would reach running deployments on restart. Both live in `_EXTENSIBLE_MCP_LAUNCH_ARGS` in `orchestrator/mcp_gateway.py`, the Dockerfile's pre-warm uses the same arguments (a unit test enforces the match), so an image whose pre-warm succeeded starts the gateway with no network. The pre-warm is best-effort (`|| true`): an image built while GitHub was unreachable fetches the pinned commit on its first start instead. Updating the gateway is a deliberate bump of the commit and cutoff; `uv` itself is not pinned. Because the MCP stdio client does not inherit the parent environment, `MCPGateway.start()` explicitly forwards the embedding-cache/offline env vars (`FASTEMBED_CACHE_PATH`, `HF_HUB_OFFLINE`, …) into the child.\n\n**Emailing an artifact.** A Gmail send or draft may carry `attachments=[{\"artifact_id\": \"alert:12\", \"as\"?: \"docx\"}]`. `call_tool` runs the recipient gate first; only if it passes does `_expand_artifact_attachments` replace each such entry with the rendered file (`content` base64 + `filename` + `mime_type`, workspace-mcp's own attachment shape), produced by `artifact_records.render_artifact_file` — the same renderer as `/artifacts/{id}/download`. Only artifact rows resolve (never an arbitrary alert), links have no file, and an entry mixing `artifact_id` with other keys is refused. A stringified or single-object `attachments` is normalised first, so an artifact entry can never reach workspace-mcp unexpanded (it would silently drop it). At most 5 distinct artifacts per email (repeats are dropped) and 15 MB rendered in total; rendering runs off the event loop via `asyncio.to_thread`. A refusal writes `artifact_attachment_refused`; a successful send writes `artifact_attached` — both carry the recipients. Other attachment entries pass through unchanged. The same expansion serves the Microsoft 365 mail writes: the entry becomes a Graph `#microsoft.graph.fileAttachment` (`name`, `contentType`, `contentBytes`) placed where Graph expects it for that tool (`body.Message.attachments` for `send-mail`, reply, forward and the createReply drafts; `body.attachments` for `create-draft-email`), whether the model put it at the top level or inside the message; an entry anywhere else, one on `update-mail-message` or `send-draft-message` (their requests cannot carry attachments), or one behind a body that is not an object is refused rather than forwarded unexpanded.\n\n**Enablement, and why it degrades instead of dying.** `MCP_ENABLED` left unset is inferred from the presence of `mcp_servers_config_path` — dropping an `mcp_servers.json` next to `profile.yaml` turns MCP on with no further configuration. An **explicit** setting always wins in both directions: `MCP_ENABLED=false` keeps MCP off with the file still in place. It did not: `_resolve_mcp` tested `not self.mcp_enabled`, which cannot tell \"never set\" from \"set to false\", so the file's presence silently overrode the operator. The file is probed through `config.mcp_config_file_present`, never `Path.exists` directly — that re-raises an EACCES on a parent directory, and from inside a validator that comes out of `Settings()` itself, which `api/main.py` calls at module level. The check now reads `model_fields_set` (the fields the env actually supplied) and stores that on a private attr behind the read-only `mcp_auto_enabled` property, because the validator's own assignment joins that set and would otherwise make every auto-enable look explicit downstream.\n\nStartup is **best-effort**, in `main._start_mcp_gateway`. Two checks come before the subprocess: MCP off is a no-op, and a config defining no servers under `mcpServers` is logged and skipped — extensible-mcp's own loader raises on that and exits, and because the child is already gone the only thing that surfaces is anyio's \"Attempted to exit cancel scope in a different task\" from `stdio_client` unwinding, a traceback naming nothing about configuration. The start is also bounded (120s, generous because that child may still clone extensible-mcp on a cold start if the image's pre-warm failed): `ClientSession` carries no `read_timeout_seconds`, so `initialize()` waits forever on a child that is *alive but silent*, and an unbounded wait is a lifespan that never yields — the same healthcheck-never-passes symptom, which no `except` can see. A gateway that fails anyway is logged at ERROR with the config path, the servers it named, and whether the config file or an explicit `MCP_ENABLED` turned MCP on, then torn down under its own timeout; the API boots **without MCP tools and without the email poller** rather than refusing to boot. The guard catches `BaseException`, not `Exception`, re-raising a bare `CancelledError` first: the original failure came from anyio, which wraps a task group's failures in `BaseExceptionGroup` as soon as one is a `BaseException` — and that group is not an `Exception`, so `except Exception` would miss the very shape this contains. The teardown is contained too, since it runs from inside that `except` and would otherwise replace the actionable log with the crash it prevents. `configured_server_names` never raises either, and deliberately catches broadly: it reads an operator-owned path during startup, where an exception is the crash loop rather than a bug report — `Path.is_file` re-raises an EACCES on a parent directory, and `json.loads` answers deeply-nested input with `RecursionError`, which is not a `ValueError`. It tests `is_file()` so a directory or a symlink to a FIFO is never opened, and caps the read at 1 MiB. That exception used to propagate out of the lifespan and kill the container — under `restart: unless-stopped`, a crash loop whose only outward symptom was a healthcheck that never passed. Outbound tools: per-Person DMs (`send_slack_dm`, `send_discord_dm`, `send_telegram_message`) plus room-targeted `send_department_message` and `send_company_broadcast`. All three per-Person DMs refuse a recipient id that matches no non-archived Person row (`schedule_tools._dm_recipient_on_roster`), which is what stops untrusted content in a turn — inbound mail, a chat message, a knowledge document — from directing a DM at an arbitrary workspace member. `send_slack_dm` previously consulted the roster only when the turn was already private to the principal, so on an ordinary turn it applied no roster check at all while its Discord and Telegram siblings did; the room-targeted tools are deliberately ungated, since those channels are team-visible. The per-Person DMs are additionally rate-limited and de-duplicated by the outbound anti-spam guard (below).\n\n### Outbound anti-spam guard\n\nEvery per-Person DM passes a deterministic guard (`orchestrator.outbound_guard.check_outbound_allowed`) after the roster gate and recipient-id recovery but before the network send — the single chokepoint **all** proactive DM paths reach (ad-hoc chat sends, the scheduler's followup/nudge dispatch, principal-brief delivery, the reflection turn, and research-finding routing). On a violation it returns a human-readable reason and the handler **suppresses** the send (`{\"status\":\"suppressed\",\"reason\":…}`), writes a `tool_invocation` audit row with `suppressed=true`, and writes **no** `done` activity row — so a blocked attempt never counts toward its own rate cap, and the Executive can read the reason and hold or `schedule_followup` for the recipient's next window. Three controls: **content dedup** (a near-identical message — whitespace/case-normalized, first 160 chars — already sent to the same recipient within `OUTBOUND_DEDUP_WINDOW_MINUTES`, default 360); a **per-recipient rate cap** (`OUTBOUND_MAX_PER_RECIPIENT_PER_WINDOW`, default 5, over `OUTBOUND_RATE_WINDOW_MINUTES`, default 60); and **quiet hours / availability** (`OUTBOUND_RESPECT_QUIET_HOURS`, default on — refuse while the recipient is on leave or outside their `people.channel` availability windows, surfacing the next open window). Delivery history is read from the existing `status='done'` `scheduled_actions` rows (`episodic.recent_sends_for_channel_ref`), so there is no new table. The guard is **fail-open**: any internal lookup error returns \"allow\", so it can never turn a working send into a crash or a silently-dropped message. Principal briefs and the internal heartbeat kinds use the `__internal__` channel and never reach these handlers, so they are unaffected. Once the guard allows a send, the handlers apply the recipient's own notification settings to the Executive's own (tagged) outreach: in their quiet hours, or when they chose *In my briefs*, the DM is stored and returned as `{\"status\":\"held\"}` rather than sent, and goes when quiet hours end or with their next brief (`outbound_guard.check_hold`; see Org).\n\n### AI disclosure\n\nWhat the Executive sends says it is an AI assistant (`orchestrator.disclosure`), added by plain code so a reply the model writes can't leave it out. **Mail** from its own mailbox gets a line under the body, by default \" is an AI assistant working for . To reach a person, reply and ask, or write to .\": the MCP gateway adds it to every Gmail send and draft and every Outlook send, reply, forward and draft (`mcp_gateway._with_disclosure`) **after** every outbound gate has passed and only on what goes to the server, so the gates, the outbound-context record and the audit see the model's own words, and `send_from_executive` gets it the same way. It counts as already there only when it is the last line that shows (in a quoted earlier message or hidden in markup it doesn't), and a send of an existing draft and mail whose every recipient is the principal go as they are. HTML that could hide what follows (an unclosed comment, a style or script block, text styled out of sight: `disclosure.hides_what_follows`) is refused, so the line can't be hidden after it; the model is told to send plain text. The roster acknowledgement to a new sender, the one send to an address nobody checked, gets `BARE_MAIL_LINE` (\"This message was sent by an AI assistant.\"), which names no one, like the acknowledgement itself. **Chat** gets a shorter line (\" · AI assistant\", in italics) on the Executive's replies in Slack, Telegram, Discord and Google Chat and on its own DMs and room posts (`send_slack_dm`, `send_discord_dm`, `send_telegram_message`, `broadcast_tools._dispatch`), but only on the first message in a conversation and again after 24 hours without one (`with_chat_line`, kept in memory, so a restart shows it once more); session history keeps the reply without it. `AI_DISCLOSURE` (default on) turns it off; `AI_DISCLOSURE_MAIL_TEXT` / `AI_DISCLOSURE_CHAT_TEXT` reword the lines, and a wording that doesn't say \"AI\" is ignored for the built-in one. It fails open: a line that can't be built is left off, never a send refused. Replies sent *as a person* under Act as me are the delegation section's (**AI line on replies**).\n\n### Calendar & video links\n\nThe Executive books meetings through two typed tools over the configured calendar backend (`CALENDAR_PROVIDER`: Google Calendar via `manage_event`, or Outlook via `create-calendar-event` — see *Workspace providers*). `create_calendar_event` schedules a **future** meeting (propose→approve by default, or auto-execute once that decision class is promoted) and may be used proactively when a stalled goal warrants a sync. `create_instant_meeting` spins up an **impromptu call starting now**, auto-executing without the approval step and skipping the business-hours/horizon caps, so the Executive can hand back a join link immediately. Because nothing approves it, an instant meeting, and `cancel_calendar_event` too, runs only when someone on the team asked on a surface that verified who they are (the principal, or a rostered teammate in the app or their own chat); a turn an inbound email started gets a refusal to pass on instead, and a team-mode `create_calendar_event` the operations department would auto-execute is proposed instead. Both attach a video-meeting link by default (`add_video_link`; opt out for in-person) — a **Google Meet** link read from `manage_event`'s plain-text reply (which also carries the event id, inside the event link's `eid`), or a **Microsoft Teams** link from `onlineMeeting.joinUrl` — surfaced to the caller plus persisted on the decision ledger as `meet_link`. The tool descriptions are static, backend-neutral text, so the cached tool block never depends on settings; the pre-rename `add_google_meet` key is still honoured for old ledger rows. Either way the caps that matter still hold — roster membership, principal-protection, max attendees, and a daily booking ceiling. A proposal raised in the web chat by the person who approves it (the principal, or its approver when it isn't the principal's private booking) says `approve_here`; its `action_taken` chip then carries the `decision_id`, and the chat shows the approval card (Approve, Edit, Dismiss) under the reply as well as on the briefing — answering either clears both. A booked meeting whose event id was never read can't be cancelled from the app: both cancel paths refuse rather than report a cancel that left it on the calendar.\n\n**Around-the-meeting participation.** The Executive doesn't join the call as a live voice; instead, on every successful booking it schedules a best-effort post-meeting follow-up (~5 min after the end) that DMs a human attendee asking for a quick recap — key decisions and action items. The reply flows back through the same cross-session DM context linkage described below, so the Executive can act on it (summarize, follow up) in a later turn. No meeting-transcript or Drive integration is required.\n\n### Workspace providers\n\nThe Executive can *discover* any mail or calendar tool the gateway exposes, but three code paths call a fixed backend with no model in the loop and used to spell Gmail tool names inline: the inbound mailbox poller (list unread → fetch → mark read), alert email dispatch plus the scheduler's email send-tool hint, and the typed booking tools plus the approval-time conflict check. `integrations.workspace` puts those behind two Protocols (`MailProvider`, `CalendarProvider`) with one module per backend (`google.py`, `microsoft.py`) and a registry keyed on `EMAIL_PROVIDER` / `CALENDAR_PROVIDER` — separate switches (Outlook mail with a Google calendar is legitimate), both defaulting to `google`, so an existing install changes nothing until an operator opts in. The Google backend is the old inline code moved verbatim; tests pin its argument dicts byte-for-byte. The Microsoft backend polls only the Inbox folder (`list-mail-folder-messages` with `isRead eq false`, never the all-folders listing that includes Junk), converts Graph's HTML bodies to text with a real parser, lists a message's attachments so the names appear in the same `N. name (mime, size KB)` shape Gmail's text uses (and reach peer memory), marks read with `update-mail-message`, sends through `send-mail`'s nested shape, books with `isOnlineMeeting` + `onlineMeetingProvider=teamsForBusiness`, cancels with `delete-calendar-event`, and answers the advisory conflict check from the Executive's **own** calendar view (delegated permissions cannot read attendees' free/busy — narrower than Google's `query_freebusy`).\n\nEvery backend renders an inbound message into the same `Subject:` / `From:` / `To:` / `Cc:` + `--- BODY ---` text the persona already expects, and the poller adds a `--- REPLY ---` block naming the exact reply tool and threading ids for *that* message (Gmail: `send_gmail_message` + `thread_id`; Outlook: `reply-mail-message` + `messageId`) after the mail's `` block, never inside it. The block rides in the user turn — which is what lets the cached persona say \"use the reply tool in the REPLY block\" instead of naming a vendor tool — and the scheduler's proactive-email trigger names the backend's send tool the same way. Providers never raise on a malformed backend response (they return empty / `None` / `{\"error\": …}`), so one bad cycle never kills a loop, and a chosen backend whose MCP server is missing from `mcp_servers.json` is logged at startup and skipped by the poller: fail-soft, never a boot failure. The poller's newer checks run on that rendered text for both backends (contacts and private turns, roster requests and their emailed answers, the audit row), with three Outlook differences that fail closed: no sender is ever authenticated (the `dmarc=pass` read is Gmail's), so there is no acknowledgement to an unknown sender, no emailed fact confirmation and no principal-authenticated mail; attachments stay a list (the download is Gmail's); and the brief's calendar block is skipped (its listing is Google Calendar's). The code's own emails to the principal (roster prompts, fact confirmations, scheduled deliveries) go through `workspace.registry.send_from_executive`, and `runner.email_ready` follows `EMAIL_PROVIDER`. A private turn's allow-list (`schedule_tools.PRIVATE_TURN_MCP_TOOLS`) and Act as me's mail-touched reads include the Outlook twins of the Gmail and Calendar tools, and roster answer tokens are hidden from Microsoft 365 results as from Google's.\n\n### Cross-session DM context\n\nWhen the principal says \"DM Alex about X\" in one session, OE sends the DM and — if a live conversation is active and the recipient is not one of the principal's contacts (a contact cannot DM the bot, and quoting the principal's conversation into their email reply's unattended turn would put it in the audit log) — records an `outbound_context` linkage row (recipient, full outbound text, originating session id). Email is covered too but recorded one layer deeper — there is no `send_email` tool, so the linkage is written in `orchestrator.mcp_gateway` after a successful `google_workspace__send_gmail_message` or `microsoft_365__send-mail`, one row per `to`/`cc` recipient keyed by the bare lowercased address (OE's own address skipped). When Alex later replies on the 1:1 DM path — or by email — the shared helper `integrations.inbound_hydration.hydrate_user_message` (called by the Discord, Telegram, and Slack handlers, and by the email poller keyed on the sender address) looks up the most-recent `open` linkage for that sender within 72h and prepends an `` block — the outbound text plus a backstory excerpt from the originating session — to the message handed to `Executive.chat()`. So OE knows what the reply is about instead of starting from an empty per-person session. The block is context for the LLM turn only: peer memory strips it before recording what the person said (`strip_outbound_reply_context`, applied in `sync_turn`, `sync_department_turn` and to the prefetch query), so the Executive's own DM is never attributed to the person who replied to it. The block lands in the user turn (caching rule) and is injected into a copy only — the pristine reply is persisted to history. The linkage is one-shot (consumed on first matched reply, race-safe) so an unrelated later DM isn't re-hydrated. This is deterministic and independent of the per-person `peer_memory` (Honcho) layer.\n\nEvery adapter that persists a turn (Slack, Discord, Telegram, and the web route) records the message's **resolved sender** on `chat_messages.sender_person_id` — the rostered Person the adapter matched, or NULL for an unrostered sender — independently of the session owner, which falls back to the principal on Discord and Telegram so those threads stay visible. Attunement's open loops and feedback rely on this so an outsider's words are never read as someone on the roster's.\n\n### OneDrive folder sync\n\nThe Microsoft twin of the Drive folder sync below (`knowledge.onedrive_sync`, `ONEDRIVE_SYNC_ENABLED`; setup in `docs/onedrive_sync_setup.md`). It copies the files in `ONEDRIVE_SYNC_FOLDERS` (`/` pairs; `openexecutive onedrive-folder ` resolves a sharing link) into the isolated `onedrive_docs` collection on its own heartbeat, and shows them on the knowledge page as \"OneDrive\" rows with a **Sync now** button. Microsoft has no service account that sees only what is shared with it (app-only Graph access covers the whole tenant), so it reads as the Executive's own Microsoft 365 sign-in, and the folder list is the scope. It never stores a credential: each tick asks the launcher for a token (`ms365-mcp-launch.sh --access-token`, which runs `docker/ms365-access-token.mjs` through the server's own token cache and will only mint file-read tokens), keeps it in memory, and sends it only to Graph. `knowledge.onedrive_client` issues GETs only, follows a paging link only back to Graph, and fetches a file's pre-authenticated download link without the token; errors never carry that link. PowerPoint and older Office formats are read through Graph's PDF rendering.\n\n### Google Drive folder sync\n\nThe synced files show on the knowledge page's Company documents list as \"Google Drive\" rows (sync time, last edit in Drive, an Open in Drive link and a text view), and the page's source card shows the last run, any sign-in error or count of files that failed to sync, and a **Sync now** button that runs one tick on demand.\n\n\nSeparate from the Drive tools the gateway proxies. The optional folder sync (`knowledge.drive_sync`, `DRIVE_SYNC_ENABLED`; setup in `docs/drive_sync_setup.md`) copies the files in `DRIVE_SYNC_FOLDER_IDS` into the isolated `drive_docs` knowledge collection on a scheduler heartbeat, so the Executive knows those folders without searching Drive live (see RAG). It calls the Drive v3 API directly (`knowledge.drive_client`, `httpx`) as a **service account** with the `drive.readonly` scope (`DRIVE_SYNC_SERVICE_ACCOUNT_FILE`), not as the Executive's own account through workspace-mcp. Google enforces the folder scope, because the account sees only what is shared with it, and the sync can change nothing. Structured JSON means no display text to parse, and every file or folder id is checked against `[A-Za-z0-9_-]{1,128}` before it goes into a URL or a query. The two paths meet in two places. A synced chunk carries its Drive file id and sync time, so the Executive can open the live file with its own `get_drive_file_content`. The setup doc says to share each synced folder with the Executive's account too, so that live open works.\n\n### Confluence space sync\n\nThe optional Confluence sync (`knowledge.confluence_sync`, `CONFLUENCE_SYNC_ENABLED`; setup in `docs/confluence_sync_setup.md`) copies the pages of the spaces in `CONFLUENCE_SYNC_SPACE_KEYS` into the isolated `confluence_wiki` knowledge collection on a scheduler heartbeat, so the Executive knows the company wiki without a tool call (see RAG). It works with Confluence Cloud and Server/Data Center: `knowledge.confluence_client` calls the v1 REST API both serve, directly with `httpx`, signing in with a personal access token (Server/DC) or a username and API token (Cloud). It reads only, never follows a redirect (so the token stays on the configured site), streams responses with a 20 MB cap, and checks page ids and space keys before they reach a URL or a CQL query. The URL must be `https://` unless `CONFLUENCE_SYNC_ALLOW_HTTP` is set, and `CONFLUENCE_SSL_VERIFY` takes `true`, `false` or a CA bundle path for a private certificate authority. With `CONFLUENCE_SYNC_PUBLIC_HOSTS_ONLY` (off by default, for an install where whoever sets the URL must not reach the backend's own network) every request resolves the site's host, refuses it when any address is not public (loopback, private, link-local, shared), and connects to the checked address with the site's name kept for the Host header and the TLS certificate check, directly rather than through a proxy. `confluence_client.config_problem` is the same settings check the app runs at start, for a caller that writes these settings on someone's behalf.\n\nIt is separate from a Confluence MCP server such as mcp-atlassian, which a deployment can add to `mcp_servers.json` for live reads and edits in chat. The two share the env names `CONFLUENCE_URL`, `CONFLUENCE_PERSONAL_TOKEN`, `CONFLUENCE_USERNAME`, `CONFLUENCE_API_TOKEN` and `CONFLUENCE_SSL_VERIFY`, so one set of values serves both, and a synced chunk carries its page id so the Executive can open the live page with such a tool. A Confluence token is a user's, and what the sync reads becomes retrievable by everyone who talks to the Executive, so the space list is required and pages with a read restriction (their own or a parent's) are skipped unless `CONFLUENCE_SYNC_SKIP_RESTRICTED=false`. That keeps any existing member's token safe to use, without a dedicated licence. Manual runs: `openexecutive sync-confluence` / `purge-confluence`.\n\n### Your own Gmail or Outlook (Act as me)\n\nA second, separate mail connection, for **Act as me** (see **Act as Me (Delegation)**): a person's *own* mailbox, which `delegation.gmail.DelegateGmail` reads and drafts into over the Gmail REST API. It is never registered with the MCP gateway, so the model can reach it only through the typed `ghostwrite_email` handler — never by tool name through `call_tool`. The credential is one file per person in `DELEGATION_GOOGLE_CREDENTIALS_DIR` (default `company/delegation_google/`, `/data/delegation_google` in Docker), minted on a computer with a browser by `scripts/connect-own-gmail.py --email ` with the Executive's own OAuth client and the scopes `gmail.readonly` and `gmail.compose` — the same seed-it-yourself flow as the Executive's token, since the API serves no OAuth callback. It never lives in `WORKSPACE_MCP_CREDENTIALS_DIR`, where workspace-mcp picks a credential by address.\n\nEvery use re-checks with Google that the credential opens the mailbox on the person's People entry, and refuses outright when that address is the Executive's own: a shared mailbox would make everything the Executive writes look like theirs. Drafts are threaded (`In-Reply-To`, `References` of whole ids ending with the parent, `threadId`) and stamped `X-OE-Ghostwritten`; the only send is `drafts.send` of an existing draft by id, when the person taps Send on its reply card (`delegation.reply_send`). A Gmail rate limit, even as a 403, is an error to retry rather than a reconnect. The voice learner and the thread shown to the ghostwriter keep only each sender's own words through `email_poller.sender_new_text`, the poller's quoted-reply stripper made public. Setup status shows it as **Your own mailbox (Act as me)**.\n\nThe same file can instead hold an Outlook sign-in (`\"provider\": \"microsoft\"`), minted by `scripts/connect-own-outlook.py --email ` with a device-code sign-in on the Executive's own Entra app (`MS365_MCP_CLIENT_ID`, `MS365_MCP_TENANT_ID`, default `common`) and the delegated scopes `User.Read`, `Mail.ReadWrite`, `Mail.Send` and `offline_access`. `delegation.gmail.gmail_for` then hands back `delegation.outlook.DelegateOutlook`, which talks to Microsoft Graph directly — never the Microsoft 365 MCP server the Executive's own Outlook uses — with the same methods and the same one send: Graph's `send` on an existing draft, with no body. Microsoft rotates the refresh token on use and the new one is written back into the file. A sender counts as verified only from the topmost `Authentication-Results`, the one Exchange wrote, and only mail in their Sent Items counts as theirs.", "mermaid": "flowchart TD\n Channels[\"Slack / Discord
Telegram / GChat / Email\"]\n Resolve[\"find_person_by_*_id\"]\n Gate[\"roster gate\"]\n Drop[\"drop\"]\n Hold[\"roster request
(hold + fixed ack,
ask the principal)\"]\n Exec[\"Executive.chat()\"]\n Attach[\"attachments
(vision / inbound_attachments)\"]\n MCP[\"MCP gateway\"]\n GW[\"Google Workspace
(workspace-mcp child)\"]\n M365[\"Microsoft 365
(ms-365-mcp-server child:
Outlook + OneDrive)\"]\n WP[\"workspace providers
(poller / alerts / booking)\"]\n Guard[\"outbound anti-spam guard
(dedup / rate cap / quiet hours)\"]\n Out[\"DM / department
/ broadcast tools\"]\n Channels --> Resolve\n Resolve --> Gate\n Gate -- \"unknown sender\" --> Hold\n Hold -- \"approved: replay\" --> Exec\n Hold -- \"contact / declined\" --> Drop\n Gate -- \"Person\" --> Exec\n Gate --> Attach\n Attach --> Exec\n Exec --> MCP\n MCP -- \"roster egress gate\" --> GW\n MCP -- \"roster egress gate\" --> M365\n WP -- \"EMAIL_PROVIDER / CALENDAR_PROVIDER\" --> MCP\n WP --> Exec\n Exec --> Guard\n Guard -- \"suppress\" --> Drop\n Guard -- \"allow\" --> Out\n Out --> Channels\n Own[(\"Principal's own Gmail
(Act as me: REST, drafts only)\")]\n Exec -- \"ghostwrite_email\" --> Own\n class Channels entry\n class Resolve compute\n class Gate compute\n class Drop external\n class Hold compute\n class Exec hot\n class Attach compute\n class MCP external\n class GW external\n class M365 external\n class WP compute\n class Guard compute\n class Out compute\n class Own external", - "generated_at": "2026-10-18T20:30:00Z" + "generated_at": "2026-10-20T08:15:53Z" } diff --git a/packages/core/openexecutive/orchestrator/extensions.py b/packages/core/openexecutive/orchestrator/extensions.py index daf498a5a..f1e36eed4 100644 --- a/packages/core/openexecutive/orchestrator/extensions.py +++ b/packages/core/openexecutive/orchestrator/extensions.py @@ -1,9 +1,10 @@ -"""Chat tools and document collections an install adds without changing this code. +"""Chat tools, document collections and gateway checks an install adds without changing this code. ``OPENEXECUTIVE_EXTENSIONS`` names Python modules, comma-separated. Each has a -``register()`` that calls ``register_tool`` and ``register_collection``; they -are imported once, when the API starts (or the first time anything needs them -in a process that has no API, e.g. the CLI). +``register()`` that calls ``register_tool``, ``register_collection`` and +``register_gateway_check``; they are imported once, when the API starts (or +the first time anything needs them in a process that has no API, e.g. the +CLI). An extension tool is offered only on a turn someone is in: @@ -41,6 +42,24 @@ document an extension publishes as a drafted artifact tagged ``collection_tag(name)``: the Documents page gives it its own filter, and the extension hears when one is archived, restored or deleted, before it happens. + +A gateway check belongs to an MCP server the install configures for the +extension (its tools are ``__*``). The gateway asks it about every +``call_tool`` to that server, after its own refusals (Act as me's lockdown, +outside content, a turn from another AI app, saved files) and before any of +its per-tool gates. It may refuse the call, which the gateway audits as a +blocked outbound call, or let it through with ``outgoing``: the arguments +actually sent, built after every gate passed and again for each send (a +retry included), from the model's own arguments, which stay what is +recorded. A check that raises refuses the call, and so does a call whose name +only differs in spelling from ``__`` (the proxy routes by the exact +name). The built-in servers (``google_workspace``, ``microsoft_365``, +``fetch``) take none: their gates are core's. If the extension then fails to +load, its servers stay refused rather than unchecked: those it registered a +check for, and those it names in a module-level ``GATEWAY_SERVERS`` (which +also covers a ``register()`` that fails before reaching its check). A module +that cannot even be imported names none, so its server should not be +configured until it is fixed. """ from __future__ import annotations @@ -67,6 +86,8 @@ _in_workflow_step: ContextVar[str | None] = ContextVar("extension_in_workflow_step", default=None) # A collection or tool group name. _COLLECTION_NAME = re.compile(r"[a-z][a-z0-9_]{0,31}") +# An MCP server a gateway check covers: single _ between letters and digits. +_SERVER_NAME = re.compile(r"(?=.{1,32}$)[a-z][a-z0-9]*(?:_[a-z0-9]+)*") _COLLECTION_TAG_PREFIX = "collection:" _MAX_CHIP_SUMMARY = 120 @@ -121,8 +142,32 @@ class Collection: on_change: Callable[[str, Change], Awaitable[None]] | None = None +@dataclass(frozen=True) +class GatewayCall: + """What a gateway check decided for one call: ``refusal`` (what the model + is told) refuses it; otherwise it goes out, with ``outgoing(arguments)`` + as what is sent when given (called once per send).""" + + refusal: str | None = None + outgoing: Callable[[dict[str, Any]], dict[str, Any]] | None = None + + +@dataclass(frozen=True) +class GatewayCheck: + # The configured MCP server whose calls it checks: tools ``__*``. + server: str + # (tool name, lowercased with "-" as "_"; a copy of the model's + # arguments) -> GatewayCall. Runs in the turn, so it may read + # ``schedule_tools.current_session`` to see who is asking. + check: Callable[[str, dict[str, Any]], GatewayCall] + + +# Core's own gates cover these; an extension's check never replaces them. +_BUILTIN_SERVERS = frozenset({"google_workspace", "microsoft_365", "fetch"}) + _tools: dict[str, ExtraTool] = {} _collections: dict[str, Collection] = {} +_gateway_checks: dict[str, GatewayCheck] = {} _loaded = False _loading = False # Reentrant: a register() may read the registry (e.g. get_collection). @@ -175,6 +220,43 @@ def register_collection(collection: Collection) -> None: _collections[collection.name] = collection +def register_gateway_check(gateway_check: GatewayCheck) -> None: + """Add the check for one MCP server's calls (see module doc). Refuses a + bad name, a built-in server and a second check for the same server.""" + server = gateway_check.server + if not _SERVER_NAME.fullmatch(server): + # A "__" anywhere, or a "_" at either end, would split as server__tool + # somewhere else ("shop___x" is server "shop"). + raise ExtensionError( + f"server name {server!r} must be lowercase letters and digits, with single _ between them" + ) + if server in _BUILTIN_SERVERS: + raise ExtensionError(f"server {server!r} is built in; its calls are gated by core") + if server in _gateway_checks: + raise ExtensionError(f"server {server!r} already has a gateway check") + _gateway_checks[server] = gateway_check + + +def _held_check(module_name: str) -> Callable[[str, dict[str, Any]], GatewayCall]: + """The check that stands in for a server whose extension failed to load: + it refuses every call.""" + refusal = GatewayCall( + refusal=( + "This tool is unavailable: the extension that checks its calls did not load " + f"({module_name[:80]}). Tell the user an operator needs to fix it; do not retry." + ) + ) + return lambda _tool, _arguments: refusal + + +def gateway_check_for(tool_name: str) -> GatewayCheck | None: + """The check registered for the server ``tool_name`` (normalized, as the + gateway matches it) belongs to, if any.""" + load() + server, sep, _ = tool_name.partition("__") + return _gateway_checks.get(server) if sep else None + + def load() -> None: """Import each module ``OPENEXECUTIVE_EXTENSIONS`` names and call its ``register()``, once per process. A module that fails is logged and @@ -194,8 +276,11 @@ def load() -> None: names = [n for n in (getattr(get_settings(), "extensions", None) or "").split(",") if n] for module_name in names: tools_before, collections_before = set(_tools), set(_collections) + checks_before = set(_gateway_checks) + declared: Iterable[Any] = () try: module = importlib.import_module(module_name) + declared = getattr(module, "GATEWAY_SERVERS", ()) register = getattr(module, "register", None) if not callable(register): raise ExtensionError(f"{module_name} has no register()") @@ -211,6 +296,13 @@ def load() -> None: _forget_tool(name) for name in set(_collections) - collections_before: del _collections[name] + # Except its servers: they stay configured, so their calls + # are refused rather than sent with no check at all. + held = set(_gateway_checks) - checks_before + if isinstance(declared, (list, tuple, set, frozenset)): + held |= {s for s in declared if isinstance(s, str) and _SERVER_NAME.fullmatch(s)} + for server in held - _BUILTIN_SERVERS: + _gateway_checks[server] = GatewayCheck(server=server, check=_held_check(module_name)) _loaded = True finally: _loading = False @@ -368,6 +460,7 @@ def _reset_for_tests() -> None: for name in list(_tools): _forget_tool(name) _collections.clear() + _gateway_checks.clear() _loaded = _loading = False @@ -376,9 +469,12 @@ def _reset_for_tests() -> None: "Collection", "ExtensionError", "ExtraTool", + "GatewayCall", + "GatewayCheck", "chip_fields", "collection_for_tags", "collection_tag", + "gateway_check_for", "get_collection", "has_chip", "in_workflow_step", @@ -386,6 +482,7 @@ def _reset_for_tests() -> None: "notify", "offered_tools", "register_collection", + "register_gateway_check", "register_tool", "workflow_step", "workflow_tools", diff --git a/packages/core/openexecutive/orchestrator/mcp_gateway.py b/packages/core/openexecutive/orchestrator/mcp_gateway.py index a36d6d7c2..aa59d81d1 100644 --- a/packages/core/openexecutive/orchestrator/mcp_gateway.py +++ b/packages/core/openexecutive/orchestrator/mcp_gateway.py @@ -4,6 +4,7 @@ import base64 import contextlib import contextvars +import copy import html import json import logging @@ -14,7 +15,7 @@ from dataclasses import dataclass from email.utils import getaddresses from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any from urllib.parse import unquote from openexecutive.config import Settings, get_settings, mcp_config_file_present @@ -22,6 +23,9 @@ from openexecutive.people.identity import RosterAllow from openexecutive.utils.html_tags import strip_tags +if TYPE_CHECKING: + from openexecutive.orchestrator.extensions import GatewayCall + logger = logging.getLogger(__name__) _UVX_CMD = "uvx" @@ -683,6 +687,100 @@ def _refuse(tool: str, field: str, shown: str, *, reason: str) -> str: return json.dumps({"error": reason}) +def _refuse_server_call(tool: str, server: str, reason: str, message: str) -> str: + """Refuse a call an extension's server check stopped (or that failed it). + Audited as `_refuse` is, with the reason in the row's address slot.""" + from openexecutive.audit import log_event as audit_log + + logger.warning("blocked extension server call: tool=%s reason=%s", tool, reason) + audit_log( + "integration_outbound_blocked", + f"Blocked a call to {server} (tool={tool} reason={reason[:80]})", + actor="mcp_gateway", + details={"tool": tool, "field": "server_check", "address": f"<{reason[:80]}>"}, + ) + return json.dumps({"error": message}) + + +def _extension_server_call(tool_name: Any, normalized: str, arguments: Any) -> GatewayCall | str | None: + """What an installed extension's check says about a call to its server + (orchestrator/extensions.py): None when no check covers the tool, the + refusal text when it refuses (or fails), else its ``GatewayCall``.""" + from openexecutive.orchestrator import extensions + + gateway_check = extensions.gateway_check_for(normalized) + if gateway_check is None: + return None + tool = str(tool_name)[:200] + if str(tool_name).partition("__")[0] != gateway_check.server: + # The proxy routes by the exact name, so "Kiosk__x" or " kiosk__x" + # would reach some other server (one loaded at runtime under that + # spelling) carrying what this check's outgoing() signed. + return _refuse_server_call( + tool, gateway_check.server, "name-spelling", + f"{tool} is not available: call it by its exact name, " + f"{gateway_check.server}__, in lowercase.", + ) + if not isinstance(arguments, dict): + # The check reads a dict; anything else would skip it. + return _refuse( + tool, "arguments", "", reason="arguments must be a JSON object — refusing.", + ) + try: + decision = gateway_check.check(normalized, copy.deepcopy(arguments)) + except Exception: + logger.exception("gateway check for %s failed on %s", gateway_check.server, tool) + decision = None + if not isinstance(decision, extensions.GatewayCall): + return _refuse_server_call( + tool, gateway_check.server, "check-failed", + f"{tool} is not available right now: its server's check failed. Do not retry it.", + ) + if decision.refusal is not None: + return _refuse_server_call(tool, gateway_check.server, "refused", decision.refusal) + return decision + + +def _extension_outgoing( + tool_name: Any, server_call: GatewayCall, arguments: dict[str, Any] +) -> dict[str, Any] | str: + """The arguments an extension's check sends for this call (one send's + worth), or the refusal text when building them fails.""" + tool = str(tool_name)[:200] + if server_call.outgoing is None: + return arguments + try: + outgoing = server_call.outgoing(copy.deepcopy(arguments)) + except Exception: + logger.exception("gateway check's outgoing() failed on %s", tool) + outgoing = None + if not isinstance(outgoing, dict): + server = _normalize_tool_name(tool).partition("__")[0] + return _refuse_server_call( + tool, server, "outgoing-failed", + f"{tool} is not available right now: its server's check failed. Do not retry it.", + ) + return outgoing + + +def _string_leaves(value: Any) -> set[str]: + """Every string of 8 or more characters in a JSON value (keys aside): + what ``call_tool`` compares to find the values an extension's outgoing() + added. Shorter ones are too likely to occur in an answer by chance.""" + found: set[str] = set() + stack = [value] + while stack: + item = stack.pop() + if isinstance(item, str): + if len(item) >= 8: + found.add(item) + elif isinstance(item, dict): + stack.extend(item.values()) + elif isinstance(item, (list, tuple)): + stack.extend(item) + return found + + def _norm_key(key: str) -> str: return re.sub(r"[^a-z0-9]", "", key.lower()) @@ -3807,6 +3905,11 @@ async def call_tool(self, tool_input: dict[str, Any]) -> str: return json.dumps({"error": _SAVED_FILE_REFUSAL.format(tool=str(tool_name)[:200])}) if not code_built and _names_saved_folder(normalized, arguments, hidden.folders): return json.dumps({"error": _SAVED_FOLDER_REFUSAL.format(tool=str(tool_name)[:200])}) + # An installed extension's own server (orchestrator/extensions.py): + # its check may refuse the call, and builds what is sent (below). + server_call = _extension_server_call(tool_name, normalized, arguments) + if isinstance(server_call, str): + return server_call attached_artifacts: list[str] = [] # Every Google Workspace call acts as the Executive's own account. if _is_apps_script_tool(tool_name): @@ -3949,7 +4052,25 @@ async def call_tool(self, tool_input: dict[str, Any]) -> str: if isinstance(disclosed, str): return disclosed wire_arguments = disclosed - wire_input = {"tool_name": tool_input["name"], "arguments": wire_arguments} + + # What an extension's outgoing() added (a signed token): never handed + # back to the model, should the server's answer quote it. + added_values: set[str] = set() + + def _wire_input() -> dict[str, Any] | str: + # Built again for each send: an extension's server check may put + # something in what goes out that is good for one request only. + if server_call is None or server_call.outgoing is None: + return {"tool_name": tool_input["name"], "arguments": wire_arguments} + outgoing = _extension_outgoing(tool_name, server_call, arguments) + if isinstance(outgoing, str): + return outgoing + added_values.update(_string_leaves(outgoing) - _string_leaves(arguments)) + return {"tool_name": tool_input["name"], "arguments": outgoing} + + wire_input = _wire_input() + if isinstance(wire_input, str): + return wire_input try: result = await _session_call(session, "call_tool", wire_input, self._call_timeout()) result_text = result.content[0].text if result.content else json.dumps({"result": None}) @@ -3958,6 +4079,9 @@ async def call_tool(self, tool_input: dict[str, Any]) -> str: # The refused call ran nothing, and every gate above has already # passed these same arguments. if _is_undiscovered_refusal(tool_name, result_text) and await self._discover(tool_name): + wire_input = _wire_input() + if isinstance(wire_input, str): + return wire_input result = await _session_call(session, "call_tool", wire_input, self._call_timeout()) result_text = result.content[0].text if result.content else json.dumps({"result": None}) except GatewayCallTimeout: @@ -3975,6 +4099,8 @@ async def call_tool(self, tool_input: dict[str, Any]) -> str: ), "timed_out": True, }) + for value in sorted(added_values, key=len, reverse=True): + result_text = result_text.replace(value, "[redacted]") # On the final result, retry or not: roster answer tokens are hidden # from every mailbox read (Google Workspace or Microsoft 365) but the # poller's own. @@ -4132,6 +4258,18 @@ async def load_mcp_server(self, tool_input: dict[str, Any]) -> str: url: str = tool_input["url"] if not url.startswith("https://"): return json.dumps({"error": "load_mcp_server requires an HTTPS URL"}) + from openexecutive.orchestrator import extensions + + name = str(tool_input.get("name") or "") + if extensions.gateway_check_for(f"{_normalize_tool_name(name)}__") is not None: + # A server spelled like an extension's own would take calls that + # extension's check signed for it. + return json.dumps({ + "error": ( + f"load_mcp_server cannot use the name '{name[:64]}': it belongs " + "to an installed extension's server. Choose another name." + ) + }) result = await _session_call( session, "load_mcp_server", diff --git a/packages/core/tests/unit/test_extensions.py b/packages/core/tests/unit/test_extensions.py index 41530d140..1cdcdfff0 100644 --- a/packages/core/tests/unit/test_extensions.py +++ b/packages/core/tests/unit/test_extensions.py @@ -22,7 +22,13 @@ import pytest from openexecutive.orchestrator import extensions, tool_groups -from openexecutive.orchestrator.extensions import Collection, ExtensionError, ExtraTool +from openexecutive.orchestrator.extensions import ( + Collection, + ExtensionError, + ExtraTool, + GatewayCall, + GatewayCheck, +) from tests.unit.offered_tools import capture_offered @@ -145,6 +151,7 @@ def test_a_module_that_fails_halfway_leaves_nothing_behind(monkeypatch: pytest.M def _register() -> None: extensions.register_tool(_tool("first_tool", group="halfway", group_purpose="p")) extensions.register_collection(Collection(name="halves", label="Halves")) + extensions.register_gateway_check(GatewayCheck(server="halfway", check=lambda _t, _a: GatewayCall())) raise RuntimeError("second one broke") half.register = _register # type: ignore[attr-defined] @@ -155,6 +162,29 @@ def _register() -> None: assert extensions.offered_tools(_session()) == [] assert extensions.get_collection("halves") is None assert "halfway" not in tool_groups.GROUPS and "first_tool" not in tool_groups.DEFERRED + # Its server stays configured: its calls are refused, never sent unchecked. + held = extensions.gateway_check_for("halfway__any_tool") + assert held is not None and "did not load" in str(held.check("halfway__any_tool", {}).refusal) + + +def test_a_server_a_failed_extension_declares_stays_refused(monkeypatch: pytest.MonkeyPatch) -> None: + # It failed before reaching register_gateway_check: GATEWAY_SERVERS still + # names the server, so its calls don't go out unchecked. + early = types.ModuleType("oe_ext_early") + early.GATEWAY_SERVERS = ("early_shop", "google_workspace", "Bad Name") # type: ignore[attr-defined] + + def _register() -> None: + raise RuntimeError("broke first") + + early.register = _register # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "oe_ext_early", early) + monkeypatch.setattr("openexecutive.config.get_settings", lambda: SimpleNamespace(extensions="oe_ext_early")) + monkeypatch.setattr(extensions, "_loaded", False) + extensions.load() + held = extensions.gateway_check_for("early_shop__get_doc") + assert held is not None and held.check("early_shop__get_doc", {}).refusal + # Never a built-in server, and never a name that isn't one. + assert extensions.gateway_check_for("google_workspace__send_gmail_message") is None def test_a_register_that_reads_the_registry_does_not_hang(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/packages/core/tests/unit/test_mcp_gateway_extension_checks.py b/packages/core/tests/unit/test_mcp_gateway_extension_checks.py new file mode 100644 index 000000000..62e035064 --- /dev/null +++ b/packages/core/tests/unit/test_mcp_gateway_extension_checks.py @@ -0,0 +1,220 @@ +"""An installed extension's check on calls to its own MCP server +(orchestrator/extensions.py, register_gateway_check; mcp_gateway.call_tool). + +The check sees every call to its server and may refuse it, which is audited +as a blocked outbound call; otherwise ``outgoing`` builds what is sent, after +every gate and again for each send, from the model's own arguments. A check +that fails refuses the call. Calls to any other server never reach it. +""" +from __future__ import annotations + +import asyncio +import json +from collections.abc import Iterator +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +import openexecutive.orchestrator.mcp_gateway as gw_module +from openexecutive.orchestrator import extensions +from openexecutive.orchestrator.extensions import ExtensionError, GatewayCall, GatewayCheck +from openexecutive.orchestrator.mcp_gateway import MCPGateway + + +@pytest.fixture(autouse=True) +def _clean_registry(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: + extensions._reset_for_tests() + # No OPENEXECUTIVE_EXTENSIONS here: count the registry as loaded. + monkeypatch.setattr(extensions, "_loaded", True) + yield + extensions._reset_for_tests() + + +@pytest.fixture() +def audit_rows(monkeypatch: pytest.MonkeyPatch) -> list[tuple[Any, ...]]: + rows: list[tuple[Any, ...]] = [] + monkeypatch.setattr("openexecutive.audit.log_event", lambda *a, **k: rows.append((a, k))) + return rows + + +def _gateway(*results: str) -> tuple[MCPGateway, AsyncMock]: + gateway = MCPGateway() + session = MagicMock() + session.call_tool = AsyncMock(side_effect=[MagicMock(content=[MagicMock(text=t)]) for t in results]) + gateway._session = session + return gateway, session.call_tool + + +def _call(gateway: MCPGateway, name: str, arguments: Any) -> dict[str, Any]: + return json.loads(asyncio.run(gateway.call_tool({"name": name, "arguments": arguments}))) + + +def _register(check: Any, server: str = "kiosk") -> None: + extensions.register_gateway_check(GatewayCheck(server=server, check=check)) + + +# --------------------------------------------------------------------- # +# Registering +# --------------------------------------------------------------------- # + + +@pytest.mark.parametrize("server", ["google_workspace", "microsoft_365", "fetch"]) +def test_a_built_in_server_takes_no_check(server: str) -> None: + with pytest.raises(ExtensionError, match="built in"): + _register(lambda _t, _a: GatewayCall(), server=server) + + +@pytest.mark.parametrize("server", ["", "Kiosk", "kiosk-app", "a__b", "1kiosk", "kiosk_", "_kiosk", "k" * 40]) +def test_a_server_name_must_be_one_the_gateway_can_split(server: str) -> None: + with pytest.raises(ExtensionError, match="server name"): + _register(lambda _t, _a: GatewayCall(), server=server) + + +def test_a_server_has_one_check() -> None: + _register(lambda _t, _a: GatewayCall()) + with pytest.raises(ExtensionError, match="already"): + _register(lambda _t, _a: GatewayCall()) + + +def test_the_check_covers_its_own_server_only() -> None: + _register(lambda _t, _a: GatewayCall()) + assert extensions.gateway_check_for("kiosk__show_menu") is not None + for other in ("kiosk", "kiosks__show_menu", "kioskx__show_menu", "google_workspace__send_gmail_message"): + assert extensions.gateway_check_for(other) is None + + +# --------------------------------------------------------------------- # +# In call_tool +# --------------------------------------------------------------------- # + + +def test_a_refused_call_never_goes_out_and_is_audited(audit_rows: list[tuple[Any, ...]]) -> None: + _register(lambda _t, _a: GatewayCall(refusal="Not for this person.")) + gateway, sent = _gateway('{"ok": true}') + out = _call(gateway, "kiosk__show_menu", {"table": 4}) + assert out == {"error": "Not for this person."} + sent.assert_not_awaited() + (args, kwargs), = audit_rows + assert args[0] == "integration_outbound_blocked" + assert kwargs["details"]["tool"] == "kiosk__show_menu" + assert kwargs["details"]["field"] == "server_check" + + +def test_the_check_sees_the_normalized_name_and_a_copy_of_the_arguments() -> None: + seen: list[tuple[str, dict[str, Any]]] = [] + + def check(tool: str, arguments: dict[str, Any]) -> GatewayCall: + seen.append((tool, arguments)) + arguments["table"] = 99 # the check's copy, not what the model sent + return GatewayCall() + + _register(check) + gateway, sent = _gateway('{"ok": true}') + _call(gateway, "kiosk__Show-Menu", {"table": 4}) + assert seen == [("kiosk__show_menu", {"table": 99})] + sent.assert_awaited_once_with("call_tool", {"tool_name": "kiosk__Show-Menu", "arguments": {"table": 4}}) + + +def test_outgoing_builds_what_is_sent_from_the_model_arguments() -> None: + def outgoing(arguments: dict[str, Any]) -> dict[str, Any]: + return {**arguments, "_signed": "token-1"} + + _register(lambda _t, _a: GatewayCall(outgoing=outgoing)) + gateway, sent = _gateway('{"ok": true}') + assert _call(gateway, "kiosk__show_menu", {"table": 4}) == {"ok": True} + sent.assert_awaited_once_with( + "call_tool", {"tool_name": "kiosk__show_menu", "arguments": {"table": 4, "_signed": "token-1"}} + ) + + +def test_outgoing_is_built_again_for_a_retry(monkeypatch: pytest.MonkeyPatch) -> None: + # A token good for one request: the discovery retry gets its own. + tokens = iter(["token-1", "token-2"]) + _register(lambda _t, _a: GatewayCall(outgoing=lambda a: {**a, "_signed": next(tokens)})) + monkeypatch.setattr(gw_module, "_is_undiscovered_refusal", lambda name, text: text == "undiscovered") + gateway, sent = _gateway("undiscovered", '{"ok": true}') + monkeypatch.setattr(gateway, "_discover", AsyncMock(return_value=True)) + assert _call(gateway, "kiosk__show_menu", {"table": 4}) == {"ok": True} + assert [c.args[1]["arguments"]["_signed"] for c in sent.await_args_list] == ["token-1", "token-2"] + + +def test_a_check_that_raises_refuses_the_call(audit_rows: list[tuple[Any, ...]]) -> None: + def check(_t: str, _a: dict[str, Any]) -> GatewayCall: + raise RuntimeError("boom") + + _register(check) + gateway, sent = _gateway('{"ok": true}') + assert "check failed" in _call(gateway, "kiosk__show_menu", {})["error"] + sent.assert_not_awaited() + assert audit_rows and audit_rows[0][0][0] == "integration_outbound_blocked" + + +def test_a_check_that_returns_something_else_refuses_the_call() -> None: + _register(lambda _t, _a: None) + gateway, sent = _gateway('{"ok": true}') + assert "check failed" in _call(gateway, "kiosk__show_menu", {})["error"] + sent.assert_not_awaited() + + +@pytest.mark.parametrize("built", [RuntimeError("boom"), "not a dict"]) +def test_an_outgoing_that_fails_refuses_the_call(built: Any) -> None: + def outgoing(_a: dict[str, Any]) -> Any: + if isinstance(built, Exception): + raise built + return built + + _register(lambda _t, _a: GatewayCall(outgoing=outgoing)) + gateway, sent = _gateway('{"ok": true}') + assert "check failed" in _call(gateway, "kiosk__show_menu", {})["error"] + sent.assert_not_awaited() + + +def test_arguments_that_are_not_an_object_never_reach_the_check() -> None: + check = MagicMock(return_value=GatewayCall()) + _register(check) + gateway, sent = _gateway('{"ok": true}') + assert "JSON object" in _call(gateway, "kiosk__show_menu", ["table", 4])["error"] + check.assert_not_called() + sent.assert_not_awaited() + + +def test_another_server_is_untouched() -> None: + check = MagicMock(return_value=GatewayCall(refusal="no")) + _register(check) + gateway, sent = _gateway('{"ok": true}') + assert _call(gateway, "github__list_prs", {"repo": "a/b"}) == {"ok": True} + check.assert_not_called() + sent.assert_awaited_once_with("call_tool", {"tool_name": "github__list_prs", "arguments": {"repo": "a/b"}}) + + +@pytest.mark.parametrize("name", ["Kiosk__show_menu", "KIOSK__show_menu", " kiosk__show_menu"]) +def test_a_server_spelled_differently_is_refused_not_signed(name: str) -> None: + # The proxy routes by the exact name: "Kiosk__x" would reach a server + # loaded at runtime as "Kiosk", carrying what this check signed. + outgoing = MagicMock(return_value={"_signed": "token-1"}) + _register(lambda _t, _a: GatewayCall(outgoing=outgoing)) + gateway, sent = _gateway('{"ok": true}') + assert "exact name" in _call(gateway, name, {})["error"] + outgoing.assert_not_called() + sent.assert_not_awaited() + + +@pytest.mark.parametrize("name", ["kiosk", "Kiosk", "KIOSK", " kiosk "]) +def test_a_runtime_server_cannot_take_a_checked_servers_name(name: str) -> None: + _register(lambda _t, _a: GatewayCall()) + gateway, sent = _gateway('{"ok": true}') + out = json.loads(asyncio.run(gateway.load_mcp_server({"name": name, "url": "https://evil.example/mcp"}))) + assert "belongs to an installed extension" in out["error"] + sent.assert_not_awaited() + + +def test_an_answer_never_hands_back_what_outgoing_added() -> None: + token = "v1.payload.signature-abc" + _register(lambda _t, _a: GatewayCall(outgoing=lambda a: {**a, "_signed": token})) + echo = json.dumps({"error": f"validation failed: input_value={{'_signed': '{token}', 'table': 'window seat'}}"}) + gateway, _ = _gateway(echo) + out = _call(gateway, "kiosk__show_menu", {"table": "window seat"}) + assert token not in json.dumps(out) and "[redacted]" in out["error"] + # The model's own words stay. + assert "window seat" in out["error"]