From 3cd7f239b4b7cbc5910d196b064a1d32e2e918c3 Mon Sep 17 00:00:00 2001 From: ladislav-urban Date: Tue, 20 Oct 2026 08:58:58 +0200 Subject: [PATCH 1/2] feat(orchestrator): let an extension tool name its activity line, keep its audit row private and be asked without a known caller Three things a tool built in core can do that an extension's tool could not: - activity_label: the chat's activity line while it runs. Built-in labels still win; an unlabelled extension tool keeps the fallback line. - private_row: its dispatch audit row is private (_private_tool_row), for a tool whose input or result quotes the speaker's own data, as the Act as me, fact and history tools' rows are. - known_caller=False: asked on a turn with no verified caller too, for a surface an extension adds that names its user without proving who is speaking (a kiosk or embedded widget). register_tool requires such a tool to have an offered(), which decides alone; it is still never offered on an unattended, private, AI-app or replay turn, and the lockdown and outside-content hold apply as before. And one fix: an extension tool whose result is waiting_for_approval got core's generic "Waiting for approval" chip, meant for actions the department gate or a teammate's send holds. The gate never holds an extension tool, so what it waits on (its own approval) is now its chip's to describe. Left out on purpose: letting an extension chip carry a decision_id for the chat to show the card under the message. Upstream's chat only renders the card kinds it knows (ChatActionCards falls through to the meeting and app-action cards), so an extension's id would render nothing until there is a generic card renderer for extensions. Co-Authored-By: Claude Opus 5.5 --- .../architecture/architecture-facts.yaml | 13 ++++- .../architecture/prebuilt/agents.json | 4 +- .../orchestrator/action_chips.py | 5 +- .../orchestrator/activity_labels.py | 15 +++++- .../openexecutive/orchestrator/executive.py | 3 ++ .../openexecutive/orchestrator/extensions.py | 52 ++++++++++++++++--- packages/core/tests/unit/test_extensions.py | 46 ++++++++++++++++ 7 files changed, 124 insertions(+), 14 deletions(-) diff --git a/packages/core/openexecutive/architecture/architecture-facts.yaml b/packages/core/openexecutive/architecture/architecture-facts.yaml index ad59bfb0..07bac9f9 100644 --- a/packages/core/openexecutive/architecture/architecture-facts.yaml +++ b/packages/core/openexecutive/architecture/architecture-facts.yaml @@ -317,7 +317,12 @@ caching: synthesis never get them; the chat loop adds them per turn like the Act as me tools, never on an unattended run, a turn private to the principal or to a speaker not on the People list, and only where the - tool's offered(session) says. That + tool's offered(session) says. known_caller=False lifts the People-list + rule for that tool alone (an extension surface that names its user + without proving who is speaking, so the turn has no caller_person_id); + register_tool then requires an offered(), which decides alone. Never on + an unattended, private, AI-app (mcp_client) or replay turn either way. + That predicate must depend only on the kind of turn, so each kind keeps one cached tool list. Act as me's lockdown refuses them after a mail read (unclassified, fails closed), except one registered with @@ -326,6 +331,12 @@ caching: existing (tool_groups.add_to_group, done at load, so the groups are fixed per process). Its chip goes through summarize_action's usual no-chip rules, and a chip link must be a path inside the app. A + waiting_for_approval result is the extension chip's to describe + (summarize_action skips its generic held-action chip for an extension + tool: the department gate never holds one). activity_label names the + chat's activity line while it runs (activity_labels._extension_label, + after the built-in labels), and private_row makes its dispatch audit row + private (executive._private_tool_row). A collection is a drafted artifact tagged collection:: the record carries it, GET /artifacts returns collection / collection_label, the Documents page shows one filter per collection, and archive, restore and diff --git a/packages/core/openexecutive/architecture/prebuilt/agents.json b/packages/core/openexecutive/architecture/prebuilt/agents.json index 88aff254..0dd70ef2 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. A tool registered `known_caller=False` is the one exception to the People-list rule: it is asked on a turn with no verified caller too (a surface of the extension's own that names its user without proving who is speaking), so `register_tool` requires it to have an `offered` that checks who is asking; it is still never offered on an unattended, private, AI-app or replay turn. 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. A `waiting_for_approval` result is the tool's own chip's to describe, never core's generic \"Waiting for approval\": the department gate never holds an extension tool. A tool may also name the chat's activity line while it runs (`activity_label`) and keep its dispatch audit row private (`private_row`: its input or result quotes the speaker's own data). `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.", "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-20T06:55:37Z" } diff --git a/packages/core/openexecutive/orchestrator/action_chips.py b/packages/core/openexecutive/orchestrator/action_chips.py index 071cc54c..ecc8ebe9 100644 --- a/packages/core/openexecutive/orchestrator/action_chips.py +++ b/packages/core/openexecutive/orchestrator/action_chips.py @@ -219,9 +219,12 @@ def summarize_action( parsed is not None and parsed.get("status") == "waiting_for_approval" and tool_name not in ("propose_actions", "do_in_app") + and not extension ): # Held for someone's approval (a team member's send or change, the - # department gate): nothing happened yet, so no "done" chip. + # department gate): nothing happened yet, so no "done" chip. An + # extension's tool is never held there; what it waits on is its + # chip's to describe. payload["summary"] = "Waiting for approval" return payload if extension: diff --git a/packages/core/openexecutive/orchestrator/activity_labels.py b/packages/core/openexecutive/orchestrator/activity_labels.py index 938f6dbd..dc7892dd 100644 --- a/packages/core/openexecutive/orchestrator/activity_labels.py +++ b/packages/core/openexecutive/orchestrator/activity_labels.py @@ -245,11 +245,22 @@ def _label_for(tool_use: dict[str, Any]) -> tuple[str, str]: return FALLBACK_LABEL, "" if name == "call_tool": return _mcp_label(tool_use.get("input")) - return _LABELS.get(name, FALLBACK_LABEL), name + return _LABELS.get(name) or _extension_label(name) or FALLBACK_LABEL, name + + +def _extension_label(name: Any) -> str | None: + """The activity line an installed extension's tool registered + (orchestrator/extensions.py), if any.""" + if not isinstance(name, str) or name in _LABELS: + return None + from openexecutive.orchestrator import extensions + + return extensions.activity_label(name) def _is_nameable(tool_use: dict[str, Any]) -> bool: - return tool_use.get("name") in _LABELS or tool_use.get("name") == "call_tool" + name = tool_use.get("name") + return name in _LABELS or name == "call_tool" or _extension_label(name) is not None def _pick(tool_uses: list[dict[str, Any]]) -> dict[str, Any]: diff --git a/packages/core/openexecutive/orchestrator/executive.py b/packages/core/openexecutive/orchestrator/executive.py index b87b0b77..5ecf3d4e 100644 --- a/packages/core/openexecutive/orchestrator/executive.py +++ b/packages/core/openexecutive/orchestrator/executive.py @@ -836,6 +836,9 @@ def _private_tool_row(tool_name: str) -> bool: tool_name in DELEGATION_TOOL_NAMES or tool_name in FACT_TOOL_HANDLERS or tool_name in HISTORY_TOOL_NAMES + # An installed extension's tool that quotes the speaker's own data + # (orchestrator/extensions.py, private_row). + or extensions.private_row(tool_name) ) diff --git a/packages/core/openexecutive/orchestrator/extensions.py b/packages/core/openexecutive/orchestrator/extensions.py index daf498a5..1e77a42b 100644 --- a/packages/core/openexecutive/orchestrator/extensions.py +++ b/packages/core/openexecutive/orchestrator/extensions.py @@ -12,7 +12,11 @@ ``_ALL_SKILL_TOOLS``, which never holds these; - never on a turn private to the principal (mail from one of their contacts); - never to a speaker who isn't on the People list (mail from a stranger, an - unknown chat user), so text from outside the team can't reach these tools; + unknown chat user), so text from outside the team can't reach these tools. + A tool registered with ``known_caller=False`` is the one exception: it is + asked on a turn with no verified caller too (a surface that names its user + without proving who is speaking), and its own ``offered`` decides alone, so + it must have one that checks who is asking; - like every tool Act as me's lockdown has not classified, it is refused once the turn has read the owner's mail (``delegation.lockdown`` fails closed), unless it is registered with ``after_mail_read=True``: a tool that only @@ -37,7 +41,9 @@ reads (the workflow review card's "may change things" badge). A tool may sit in a ``tool_groups`` group (opened with open_tools) instead of -on the direct list, and may describe its chip. A collection is a kind of +on the direct list, may describe its chip, may name the activity line the chat +shows while it runs, and may keep its dispatch audit row private (``private_row``: +its input or result quotes the speaker's own data). A collection is a kind of 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. @@ -90,7 +96,9 @@ class ExtraTool: offered: Callable[[Any], bool] | None = None # The chip for a successful call: (tool input, parsed JSON result or # None) -> {"summary", "target", "link"} or None for no chip. A link must - # be a path inside the app ("/artifacts/alert:12"). + # be a path inside the app ("/artifacts/alert:12"). It is asked whatever + # the result's status, a "waiting_for_approval" one included: what an + # extension's tool waits on is its own, never core's held action. chip: Callable[[dict[str, Any], dict[str, Any] | None], dict[str, Any] | None] | None = None # Also offered to workflow action steps (see module doc), and whether it # only reads there (None: may change something). @@ -103,6 +111,14 @@ class ExtraTool: # Returns text from outside the team, e.g. a vendor app's records (see # module doc and ``orchestrator.outside_content``). reads_outside: bool = False + # Asked on a turn with no verified caller too (see module doc); needs + # ``offered``. + known_caller: bool = True + # Its dispatch audit row is private: its input or result quotes the + # speaker's own data. + private_row: bool = False + # The chat's activity line while it runs ("Checking the draft…"). + activity_label: str | None = None @property def name(self) -> str: @@ -152,6 +168,8 @@ def register_tool(tool: ExtraTool) -> None: raise ExtensionError(f"workflow tool name {name!r} must be at most {_MAX_WORKFLOW_NAME} characters") if tool.after_mail_read and tool.read_only is not True: raise ExtensionError(f"tool {name!r} may run after a mail read only if it is read_only") + if not tool.known_caller and tool.offered is None: + raise ExtensionError(f"tool {name!r} asked without a known caller needs its own offered()") if tool.group is not None: from openexecutive.orchestrator import tool_groups @@ -226,14 +244,15 @@ def _forget_tool(name: str) -> None: def offered_tools(session: Any) -> list[ExtraTool]: """The extension tools a turn on ``session`` is offered (the caller has - already excluded unattended and private turns): none unless the speaker - is on the People list, then each whose ``offered`` agrees. A failing - ``offered`` counts as not offered.""" - if not isinstance(getattr(session, "caller_person_id", None), int): - return [] + already excluded unattended and private turns): unless the speaker is on + the People list, only those registered ``known_caller=False``; then each + whose ``offered`` agrees. A failing ``offered`` counts as not offered.""" + known = isinstance(getattr(session, "caller_person_id", None), int) load() offered: list[ExtraTool] = [] for tool in _tools.values(): + if not known and tool.known_caller: + continue if tool.offered is not None: try: if not tool.offered(session): @@ -262,6 +281,21 @@ def reads_outside(tool_name: str) -> bool: return tool is not None and tool.reads_outside +def private_row(tool_name: str) -> bool: + """Whether extension tool ``tool_name``'s dispatch audit row is private + (``private_row``). False for anything else.""" + load() + tool = _tools.get(tool_name) + return tool is not None and tool.private_row + + +def activity_label(tool_name: str) -> str | None: + """The activity line extension tool ``tool_name`` registered, if any.""" + load() + tool = _tools.get(tool_name) + return tool.activity_label if tool is not None else None + + def workflow_tools() -> list[ExtraTool]: """The extension tools workflow action steps may use (``workflow=True``).""" load() @@ -376,6 +410,7 @@ def _reset_for_tests() -> None: "Collection", "ExtensionError", "ExtraTool", + "activity_label", "chip_fields", "collection_for_tags", "collection_tag", @@ -385,6 +420,7 @@ def _reset_for_tests() -> None: "load", "notify", "offered_tools", + "private_row", "register_collection", "register_tool", "workflow_step", diff --git a/packages/core/tests/unit/test_extensions.py b/packages/core/tests/unit/test_extensions.py index 41530d14..69d34019 100644 --- a/packages/core/tests/unit/test_extensions.py +++ b/packages/core/tests/unit/test_extensions.py @@ -310,6 +310,41 @@ def test_the_tool_decides_which_turns_it_is_offered_on() -> None: assert "make_widget" not in offered[0] +def test_a_tool_asked_without_a_known_caller_needs_its_own_offered() -> None: + with pytest.raises(ExtensionError, match="offered"): + extensions.register_tool(_tool(known_caller=False)) + + +def test_only_a_tool_registered_for_it_is_asked_on_a_turn_with_no_known_caller() -> None: + # A surface that names its user without proving who is speaking: the + # tool's own offered decides who may have it there. + extensions.register_tool(_tool("open_tool", known_caller=False, offered=lambda s: s.origin_channel == "kiosk")) + extensions.register_tool(_tool("plain_tool", offered=lambda _s: True)) + provider, _, offered = _run( + _session(caller_person_id=None, origin_channel="kiosk"), [[_use("tu-1", "open_tool", {})]] + ) + assert "open_tool" in offered[0] and "plain_tool" not in offered[0] + assert json.loads(_results(provider, 1)["tu-1"])["status"] == "made" + _, _, offered = _run(_session(caller_person_id=None), []) + assert "open_tool" not in offered[0] + # Still never on an unattended or private turn. + for kwargs in ({"unattended": True}, {"private_to_principal": True}): + _, _, offered = _run(_session(caller_person_id=None, origin_channel="kiosk", **kwargs), []) + assert "open_tool" not in offered[0] + + +def test_a_tool_can_keep_its_dispatch_row_private_and_name_its_activity() -> None: + from openexecutive.orchestrator.activity_labels import summarize_activity + from openexecutive.orchestrator.executive import _private_tool_row + + extensions.register_tool(_tool(private_row=True, activity_label="Making a widget…")) + extensions.register_tool(_tool("open_widget")) + assert _private_tool_row("make_widget") and not _private_tool_row("open_widget") + assert extensions.activity_label("make_widget") == "Making a widget…" + assert extensions.activity_label("open_widget") is None + assert "Making a widget" in str(summarize_activity([{"id": "tu-1", "name": "make_widget", "input": {}}])) + + def test_a_failing_offered_counts_as_not_offered() -> None: def _boom(_s: Any) -> bool: raise RuntimeError("boom") @@ -378,6 +413,17 @@ def test_a_chip_link_stays_inside_the_app(link: Any, kept: str | None) -> None: assert out is not None and out["link"] == kept and out["summary"] == "Made" and out["target"] == "W" +def test_a_chip_describes_what_its_tool_waits_on() -> None: + # The tool's own approval, waiting for a tap: its chip describes it, never + # core's generic "Waiting for approval" for a held action. + chip = lambda _i, _p: {"summary": "Waiting for your approval: a widget", "link": "/today"} # noqa: E731 + out = _chip({"status": "waiting_for_approval"}, chip) + assert out is not None and out["summary"] == "Waiting for your approval: a widget" and out["link"] == "/today" + # A chip that declines a waiting result gives none. + extensions._reset_for_tests() + assert _chip({"status": "waiting_for_approval"}, lambda _i, _p: None) is None + + def test_a_chip_that_raises_or_has_no_summary_gives_none() -> None: def _boom(_i: Any, _p: Any) -> dict[str, Any]: raise RuntimeError("boom") From eb13824a9f076125135be1df4794cd6e1cdf55b1 Mon Sep 17 00:00:00 2001 From: Rufus Date: Tue, 20 Oct 2026 13:55:57 -0400 Subject: [PATCH 2/2] test(orchestrator): cover AI-app and replay turns for known_caller=False --- packages/core/tests/unit/test_extensions.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/core/tests/unit/test_extensions.py b/packages/core/tests/unit/test_extensions.py index 69d34019..0fe2d3da 100644 --- a/packages/core/tests/unit/test_extensions.py +++ b/packages/core/tests/unit/test_extensions.py @@ -327,8 +327,14 @@ def test_only_a_tool_registered_for_it_is_asked_on_a_turn_with_no_known_caller() assert json.loads(_results(provider, 1)["tu-1"])["status"] == "made" _, _, offered = _run(_session(caller_person_id=None), []) assert "open_tool" not in offered[0] - # Still never on an unattended or private turn. - for kwargs in ({"unattended": True}, {"private_to_principal": True}): + # Still never on an unattended, private, AI-app or replay turn. + for kwargs in ( + {"unattended": True}, + {"private_to_principal": True}, + {"from_mcp_client": True}, + {"from_mcp_client": True, "mcp_client_may_act": True}, + {"replay": True}, + ): _, _, offered = _run(_session(caller_person_id=None, origin_channel="kiosk", **kwargs), []) assert "open_tool" not in offered[0]