ADR 0046: Approval and notifications over MCP
Date: 2026-10-02. Status: accepted; built 2026-10-02 (see “Built” at the end). Amends
ADR 0044 (the MCP face’s host effects, left open there) and
ADR 0011 (approval for a client without elicitation, by design
impossible there). Settles the question paused on 2026-09-17 and the MCP part of the
escalations proposal; the proposal’s other parts (an inquiry verb,
the result channel) are not taken up. Amended by ADR 0062, built 2026-10-12: the pending channel carries a third kind of
request, the model’s question (<id>.question.json, wisp questions), and the race of the channel against the
client’s dialog is reused for it with a fixed 60 s hold. Amended by ADR 0048: the pending
channel carries a second kind of request, a fact a caller asked to keep as permanent, answered with
wisp facts keep|drop or in wisp-tui under the keep-facts effect.
Context
Under wisp mcp a command that needs approval is asked through MCP elicitation, which the client renders.
Two things do not fit that:
- Clients without a dialog. On 2026-09-17 the operator found that the Claude mobile app does not show
elicitation, so a delegated
git pushwaited out the timeout and was refused. ADR 0011 made that refusal deliberate: a client without elicitation cannot approve, and the calling harness runs risky commands itself. The operator wants wisp to keep asking (not--yes, not a lower threshold) and for the question to reach them wherever they are. The question was paused that day as needing more thought. - A dialog that sticks. Between 2026-09-19 and 2026-09-23, Claude Code’s elicitation dialog stuck
intermittently: visible, but Accept could not be reached; three of eight approvals in one session, two of
them the
git pushright after agit commiton the same thread. The timeout bounds the damage; until this decision the only way out was/mcp reconnect wisp.
And for notifications: MCP has no notification primitive. ADR 0044 gave notify a route through each face;
under MCP it takes the process routes (the terminal app by bundle id, then osascript), never the
terminal route, since a server started by a client in a terminal shares that terminal (probed 2026-09-30).
The calling agent does not learn that a turn posted one.
Considered for approval without elicitation, in the discussion of 2026-09-17 and again on 2026-10-02:
| Option | What it does | Why not, or why |
|---|---|---|
| (a) Refuse | ADR 0011 as it stood: the reply says approval was needed, and the caller runs the command itself | Leaves the person who delegated from a phone with nothing; the paused question asked for better |
| (b) Through the conversation | The result carries an approval id bound to the command; the agent asks the person in chat and calls again with the id (the escalations proposal’s result channel) | The agent is the party being gated: it can call again with the id without asking anyone, and wisp cannot tell. The operator’s words: the calling agent can never approve on the person’s behalf |
| (c) Through another face | The request waits; the person is told by a notification and answers where wisp itself asks: wisp approvals, or a running wisp-tui | Chosen, on 2026-10-02 |
Decision
-
Approval through another face. When a command needs approval under
wisp mcp, the request waits, bounded byapproval.timeoutSeconds(0waits for ever), and the person is told: a notification through the session’s routes, sourceapproval, naming the command and how to answer it (“wisp: approval needed” — “git push origin main — wisp approvals approve a1b2c3d4”). The person answers where wisp asks:wisp approvals pendinglists what waits;wisp approvals approve ID [--scope once|session|project|always]andwisp approvals deny IDanswer, from a terminal;- a running
wisp-tuishows each waiting request as its approval dialog, marked as coming fromwisp mcp, queued behind any dialog already open.
An unanswered request is denied and audited as timed out (ADR 0011’s rule). The scopes mean what they mean in chat, with the same downgrade of a dangerous command to
session(ADR 0014). -
Nobody approves through the MCP conversation. No tool argument, resource, or result answers a request. The calling agent learns that approval was asked for (the progress line, the receipt, the
notificationslist) but has no way to give it. -
With elicitation as well: both at once, the first answer wins. The dialog and the out-of-band request are raised together; whichever is answered first decides, and the other is withdrawn: the request file removed (a
wisp-tuidialog showing it closes with “answered elsewhere”), or the dialog cancelled withnotifications/cancelled. On by default, underapproval.outOfBand(true). The reasons: it is the way out when the client’s dialog sticks, which happened often enough to name; the person at the terminal answers the dialog as before and the out-of-band request is withdrawn within a poll; the cost is one notification per request. Off restores ADR 0011 as it stood: elicitation only, and a client without it refused with the reason. -
The call waits.
tools/callstays open until the answer, as with elicitation. A client’s own tool-call timeout may end it first (Claude Code’sMCP_TOOL_TIMEOUT). A client that then sendsnotifications/cancelledcancels the handler: the request is withdrawn and audited asabandoned. A client that stops waiting without cancelling leaves the request until it is answered or times out, and the result goes nowhere. -
notifyover MCP keeps the process routes (the app route under the client terminal’s bundle id,osascriptotherwise), andrespond’s result lists every notification its turn posted, or tried to, asstructuredContent.notifications: title, body, source, outcome, route taken, and time. MCP has no notification primitive; this is how the caller knows. -
The terminal route stays refused under MCP, for ADR 0044’s reason: the server’s controlling terminal is the client’s screen, and a sequence written from the server would interleave with the client’s frames.
The channel
The MCP server and the face that answers are separate processes. The channel between them is a directory,
~/.wisp/pending/, chosen over a Unix domain socket:
| Directory of request files | Unix domain socket | |
|---|---|---|
| Discovery | wisp approvals pending reads one directory, whatever servers are running | One socket per server, so a directory of sockets to find and connect to |
| A crash | A request names its server’s process; a request whose process has gone, or whose wait has expired, is stale: never listed, removed by the next sweep | A stale socket file to detect and remove, the same problem |
| Answering | Write one file | A protocol, a connection, framing, partial reads |
| Watching | Poll at 200 ms, a stat and a missing file per waiting request | Event-driven, no polling |
| Trust | The same boundary as approvals.json: user-only files under the user’s home | The same, with socket permissions |
Polling is the price of the directory, and it is small: a waiting request costs one file check every 200 ms. FSEvents was not used: its batching adds latency to an answer the person is waiting on, and it gives nothing a poll of one file does not.
- Files.
pending/is mode 0700 and must be a directory of the user’s that no one else can open, or the channel refuses to use it (wisp doctorreports it). A request is<id>.request.json, mode 0600, written by rename from a temporary file so it is never read half-written. An answer is<id>.answer.json, written by a hard link from a temporary file, which fails if an answer is already there: the first answer wins and is always whole. - Binding. A request carries a SHA-256 over its id, command, whole line, pattern, directory, level, thread, server process, and creation time. The answering process recomputes it from the fields it shows the person and refuses a request whose file no longer matches (altered after it was filed); the answer carries the recomputed binding; the server takes only an answer whose binding equals the one it computed when it filed the request, from memory. So an answer approves only the command the person was shown, in that directory, for that thread. The id is fresh for every request and taking an answer removes both files, so an answer cannot be replayed onto another command or used twice.
- The id is not a secret. Any process running as the person can list the directory, so the id’s
randomness is not what stops an agent answering. What does:
- nothing in MCP answers;
- wisp’s own model cannot: the sandbox does not let a command write under
~/.wisp, and the default policy refuseswisp approvals approve|denyoutright; wisp approvals approve|denyanswer only from a terminal (standard input a TTY), which an agent’s shell tool does not have. This is a speed bump, not a wall: a process can obtain a pseudo-terminal. The wall for an agent’s own shell is the agent’s harness, which asks the person before running a command of its own. Kept by the operator on 2026-10-03, to be judged in use; the stronger alternative, an answer token given only in the notification and kept out of the listing, is the next step if it proves too weak.
- Clean-up. The server removes its request when it stops waiting for any reason, and sweeps its own
leftovers when the client disconnects.
wisp approvals pendingandapprovesweep stale requests, orphaned answers, and temporary files first, and audit each stale request asapproval.settledwith outcomestale. - Audited, every step:
approval.pendingwhen filed (orfailed), thenotification,approval.answeredin the answering process (viacliortui, with whether the server took it), andapproval.settledon the asking thread (answeredwithviaelicitation,cli, ortui;timed-out;abandoned;failed;stale), thenapproval.decidedas for every face.
Consequences
- A client without elicitation can delegate risky work again: the Claude mobile app’s user approves from
a terminal or
wisp-tuion the Mac, prompted by a banner. If they are not at the Mac, the request times out as before. - A stuck Claude Code dialog has a way out without reconnecting:
wisp approvals approve, or thewisp-tuidialog. - With a working dialog, every approval also posts a banner. If that proves noisy, the choice is between
turning
approval.outOfBandoff and delaying the banner until the dialog has gone unanswered for a while; the second is not built. - The person must be at the Mac. Answering from a phone, or anywhere off the Mac, is not provided.
wisp chat --jsongains an effect a front end may declare,approve-mcp, and a line,withdrawn. Both are additive: a front end that does not declare it sees nothing new.- Plain terminal chat (
wisp chat --plain) does not show waiting MCP requests: it reads a line at a time and has no place to put a dialog that arrives between prompts.
Built
Built 2026-10-02. PendingApprovals is the channel; OutOfBandApprover files, notifies, and races the
channel against the client’s dialog; Session.mcpApprover composes it from the configuration, and
WispServer gives it the elicitation leg. The Approver protocol gained decide(_:audit:), which the
gate calls with the asking thread’s log, and ApprovalRequest gained thread. Where the build chose:
- Withdrawing a dialog. The MCP SDK does not return the JSON-RPC id of a request it sends, so each
approval dialog carries a key in its
_meta(wisp/approval), and the server’s transport wrapper, which sees every outgoing message, records the id of theelicitation/createcarrying it. Cancelling the dialog’s task sendsnotifications/cancelledwith that id. Whether Claude Code closes the dialog on it is not yet probed; the specification says a client should. - A dialog that cannot be sent is not a refusal while the other way can still answer: a failure of one way leaves the other asking, and only when both have failed is the command refused.
wisp-tuideclaresapprove-mcpin itshello;wisp chat --jsonthen lists the channel every 500 ms and sends each new request as anapprovalline withsource: "mcp",thread,client, andrequest, under the idmcp-<id>, and awithdrawnline when it no longer waits. The dialog says “waiting in wisp mcp for claude-code, thread git” and its title ends “· wisp mcp”.- The approval notification goes through the per-minute limit like any other, so a burst can lose a
banner; the request still shows in
wisp approvals pendingandwisp-tui. - Tested without the model: the channel (filing, answering, binding, an altered request, a replayed
answer, the first answer winning, staleness and sweeping, an open directory refused, concurrent
requests), the approver (each way winning, the other withdrawn, silence, cancellation, a failed way), the
MCP path over the wire (a client without elicitation approved and denied from the command line; with
elicitation, each way answering first, and the client receiving
notifications/cancelled), the relay and the TUI’s queue and withdrawal, andrespond’snotifications.
Amendment, 2026-10-09: an MCP approval waits for an empty input box
A request from wisp mcp can arrive at any moment, so in wisp-tui it no longer takes the band over
while the input box has text: it waits in the queue, a note says so, the status line counts it
(⚠ 1 waiting), and its dialog opens when the box is empty (the line sent or cleared). Every dialog then
has a 500 ms grace before its keys answer it (ADR 0029, amendment of the same date). A withdrawn line now
also closes an open choice, and wisp sends one when its own approval or choice times out; the note reads
⚠ no longer waiting: …, since it may have been answered elsewhere or have lapsed.
Review, 2026-10-12: the terminal-only answers kept
wisp approvals approve|deny and wisp facts keep|drop answer only with standard input a terminal (“Decision”,
above; ADR 0048 took the same rule). The rule was kept on 2026-10-02 to see how it behaves, and reviewed with the
use since on 2026-10-12. Kept, because an agent with a shell must never answer its own approvals: the agent that
asked for a command, or for a fact to be kept, runs commands through a shell tool with no terminal, and the check
stops it answering for the person by accident or by design. It is still a speed bump, not a wall (a process can
obtain a pseudo-terminal); the default policy’s refusal of these commands to wisp’s own model and the sandbox’s
refusal to write under ~/.wisp stay beside it. The person has two other ways to answer, neither of them a shell:
wisp-tui’s dialog and the MCP client’s own elicitation dialog.
Amendment, 2026-10-12: the banner waits for the dialog
“Consequences” left a choice open: with a working dialog every approval also posted a banner, even one the person
answered in the dialog at once, and the way out was to turn approval.outOfBand off (losing the out-of-band
answer when a dialog sticks) or to delay the banner. The delay is now built, for 0.24.0.
- When the client’s dialog asks too, the banner waits. It is posted only if the request is still waiting
approval.bannerDelaySecondsafter it was filed; answered first, by the dialog or bywisp approvalsorwisp-tui, or withdrawn (the call cancelled, the request removed), no banner is ever posted. The request is filed at once as before, sowisp approvals pendingandwisp-tuishow it from the start; only the banner waits. - Default 10 seconds. A person at the terminal answers a dialog they are looking at within a few seconds, and
the answer reaches the server within a poll (200 ms), so a shorter delay would still post banners for answered
dialogs; a longer one leaves a stuck dialog unannounced for longer, when the banner is the only way the person
learns the request waits. Ten seconds is 1/60 of the default wait (
approval.timeoutSeconds, 600).0posts at once, as before. The setting takes 0 to 3600 and must be shorter thantimeoutSecondswhen that is not0, since a delay as long as the wait would never post before the request timed out; a file that breaks either rule is refused on load and bywisp config set. - Without a dialog, at once. A client without elicitation, or one whose dialog could not be sent (the dialog’s leg failed), has nothing else showing the request, so the banner is posted at once, as this ADR decided for the mobile app’s user (“Consequences”, first bullet).
- The delay is an
Alarm, on the alarm queue, never aTask.sleepon the cooperative pool (docs/design.md, “Concurrency”); nothing waits on it, and the approver cancels it when the request settles.DelayedNoticeis the reusable piece: “post after N s unless settled”, each notice posted at most once whether the alarm, an immediate post, or the settling comes first. - Audited.
approval.pendingcarriesbannerDelaySecondswhen the banner waits;approval.settledcarriesbanner,postedorskipped(settled within the delay, so none was posted); thenotificationevent is recorded when, and only if, the banner is posted.respond’snotificationslists it when it was posted during the turn. - Facts to keep are unchanged. A fact request (ADR 0048) has no client dialog, and the caller’s call returns at once, so its banner is the only announcement; it is posted at once, by the rule above.
Tested without the model: DelayedNotice (settled within the delay, outlasting it, zero, an immediate post), the
approver (a dialog answered within the delay, one unanswered past it, a withdrawn request, no dialog, a dialog
that cannot be sent, a zero delay), the setting’s validation on load and in wisp config set, and the MCP path
over the wire (a dialog answered at once posts nothing, one left unanswered posts after the delay, a client
without elicitation is told at once whatever the delay).
Note, 2026-10-12: plain chat shows and answers waiting requests
“Consequences” said plain terminal chat does not show waiting MCP requests, having no place for a dialog. It now
shows them without one (planned for 0.24.0, docs/roadmap.md): before each prompt, wisp chat --plain notes once
each request newly waiting in ~/.wisp/pending, a command to approve, a fact to keep
(ADR 0048), or the model’s question (ADR 0062),
with its id, and the person answers with a typed slash command, /approvals approve ID [once|session|project|always], /approvals deny ID, /facts keep ID, /facts drop ID, /questions answer ID
(which reads the answer at a prompt of its own, as wisp questions answer does), or /questions decline ID;
/approvals pending lists what waits. Nothing is drawn over a half-typed line: a request that arrives while the
person is at the prompt is noted at the next prompt, and its notification announces it meanwhile. A request seen and
gone is noted once with how it ended, read from the servers’ approval.settled events in the audit log (“answered
in wisp-tui”, “withdrawn: its server stopped”), or as “answered elsewhere or withdrawn” when the log has no word of
it; one answered in the chat is not noted again. Quiet, as waiting is safe: one line per request, and the answering
commands named once per kind.
The answer goes through the channel’s checks unchanged (still waiting, the right kind, the binding, the first answer
winning), is written with via chat, and is audited as approval.answered on the chat’s session; a fact whose
server has stopped is applied by the chat, as wisp facts keep applies it. The terminal-only rule holds: the chat
answers only when its standard input is a terminal, so a process piping lines into wisp chat --plain cannot answer
for the person; such a chat still notes what waits and says where to answer. The commands are the person’s typed
lines; no tool of the model reaches them. In wisp-tui the same commands answer from its input (via tui), beside
its dialog. Built as ChatPending; tested without the model: a request noted once, nothing when nothing waits, an
answer through the slash command taken by a stand-in server, a fact kept and dropped, one answered elsewhere and one
withdrawn each reported once, and a tampered, stale, unknown, or other-kind request refused.