HITL Elicitation & the Governor
Human-in-the-loop elicitation
Section titled “Human-in-the-loop elicitation”Sensitive operations require explicit human confirmation before they run. The server issues an MCP elicitation request; the action proceeds only once the human approves. The approval is bound to the exact vault, tool, argument hash, and issuing caller, so on a multi-caller HTTP deployment one caller cannot redeem another’s approval.
Single-use, precisely: it depends on which mechanism cleared the gate. The
obsidian-tc elicit CLI token and the legacy server-initiated shim (stdio) are
genuinely single-use — the CLI token is consumed with UPDATE ... WHERE consumed_at IS NULL, and the shim’s confirmation never leaves the server at
all, so there is nothing a client could replay. The 2026-07-28 client-driven
requestState (SEP-2260/2322) is replayable within its TTL — a deliberate,
documented trade (see elicit-request-state.ts): the codec authenticates and
expires the state but does not consume it, so a captured requestState can
authorize the same call more than once until it expires. Either way,
tc.elicit.consumed fires once per handler run (not once per approval), and a
fresh confirmation (tc.elicit.requested) is required for the next sensitive
call.
A refused call gives its token back only when nothing could have run. A CLI token is released (left unspent, with its original expiry and bindings) only when the server positively knows the call had no effect: it was refused after the confirmation gate but before the tool’s handler was entered (the central folder ACL, a cancellation), or the vault write gate refused it with convention_blocked before any write of the call. The corrected call can then be retried with the same token and the same arguments. Every other failure keeps the token single-use: a timeout, a 5xx or an operation_timeout from a companion plugin after the request was sent (the action may already have run), a fault after the handler returned, or any error the handler raised itself. Each release is recorded as an hitl_released event. A custom verifyElicit hook releases only if it implements release. A confirmation a tool checks inside its own handler is released through the built-in verifier, whatever verifyElicit hook is configured, when the handler’s write gate refuses the confirmed call with convention_blocked before any write. Today only update_task reaches this: it has no conventions preflight, so the write gate judges a note that already breaks a block rule as a new note when the caller lacks read:notes or the note is over 1 MiB. The tools that preflight conventions refuse before the confirmation is spent (write_note, move_note, copy_note, update_frontmatter, commit_wiki_page) never spend the token on that refusal.
Stale confirmations are refused. An approval covers the state of the target as it was
when the request was raised, not just the tool and arguments. Every HITL-gated tool declares
what its confirmation is about, either the target paths it declares for the folder ACL
(pathAcl) or a confirmationTargets function on its definition. When that state changes
between the request and the redemption, the server answers replay_drift and applies nothing.
The token is spent; re-issue the original call with no token to request a fresh confirmation.
This holds for every route a confirmation can arrive by: a token minted by obsidian-tc elicit,
the stdio form round trip, the 2026-07-28 requestState, and the lean facade’s
call_capability.
| Tool | What the confirmation is bound to |
|---|---|
rewrite_link |
the set of notes the rewrite would touch, and their content: a note changing, or a new note gaining a link to the target |
ocr_bulk |
the attachments it would read: a file replaced, or one added under root |
git_commit |
HEAD and the staged index (path, mode, blob id); stat-only index refreshes are not drift |
reset_vault_cache |
the chunk and committed-capture counts and the embedding generations it would clear |
delete_entity |
the entity row, its relations and its materialized note |
session_rerun |
the session row and, once the session has ended, its recorded trace |
execute_command, trigger_quickadd |
nothing (confirmationTargets: "none") |
Registering a HITL-gated tool with neither pathAcl nor confirmationTargets throws at
startup, so a new gated tool cannot silently bind on the arguments alone.
Residuals, stated rather than hidden. execute_command and trigger_quickadd produce effects
inside Obsidian that the server cannot observe, so they bind on args_hash alone. git_commit
reads the vault’s own .git on the server host; when the Obsidian Git bridge runs against a
repository on another machine and the vault has no repo here, that binding is empty. An open
session’s trace is not part of session_rerun’s binding, because it grows with the calls around
the request itself. reset_vault_cache leaves out the event log and idempotency keys, which the
confirmation flow writes itself. A vault with no root wired (some embedded uses) cannot bind
path-based targets.
A token minted with no raised request is refused by the CLI. obsidian-tc elicit holds an
args_hash, not the call’s arguments, so it cannot recompute what a tool’s confirmationTargets
fingerprints; it binds the token to the state recorded when the server raised elicit_required
(for every tool, including the "none" ones, which record an empty fingerprint). With no raised
request behind the hash it would mint a token that redeems on args_hash alone, so it exits
with an error asking you to run the blocked call again first (--caller must match the caller
that was blocked). Library callers of issueElicitToken are unchanged: a token issued directly
with no raised request stays unbound.
The elicitation thresholds are hardcoded floors — a client cannot configure them away. This keeps the confirmation gate present even under a permissive config.
What keys the confirmation state
Section titled “What keys the confirmation state”The requestState the HTTP transport mints is signed and expires after the confirmation TTL
(300 s by default). Its key comes from a per-server secret, <cacheDir>/server-secrets/wiki-generated.key
(random, 0600, created on first use, the same file that seals generated wiki pages), run through HKDF
under a label of its own. It does not come from auth.jwtSecret, so the round trip exists in every
HTTP auth mode (jwt with a secret, jwt with only a JWKS or registry keys, oidc), and rotating or
removing jwtSecret does not void a pending confirmation. Processes that share a cacheDir accept each
other’s states; a server with a different cacheDir does not. Deleting server-secrets/ voids pending
confirmations (the client is offered a fresh one) and regenerates the wiki pages.
Which key is in use is logged at startup: an elicit: line on stderr says either that the codec is
keyed from <cacheDir>/server-secrets (stable across restarts) or, when the server was given no
cacheDir, from a per-process random key (confirmations do not survive a restart and are not shared
between processes). Keep server-secrets/ on the same persistent volume as auth-keys/ (in a
container, mount the whole cacheDir): a server-secrets/ that is recreated empty on every start
silently voids pending confirmations and makes every generated wiki page read as edited.
auth.mode: none. The round trip works there too, which is only reachable on a loopback bind.
There is no token to take an identity from, so every request is the one caller http-local and a
confirmation is bound to it: any process on the machine that can reach the port is that caller, and
can replay a state it captured within the TTL (see below). Use jwt or oidc when more than one
principal can reach the port.
Replay widens with the modes. A requestState is not consumed, so within its TTL (300 s by
default) the same principal can present it again for the same vault, tool and arguments. Before
this keying the round trip existed only under jwt with a secret; it now also covers oidc,
asymmetric-only jwt, and servers that share a cacheDir, so the same replay window applies to
all of them. A consumed-state store would close it; none exists yet.
Rotating the key. See Rotating the server secret.
Upgrading. A confirmation that was pending when the server was upgraded was keyed from
auth.jwtSecret and is refused once; the client is offered a fresh confirmation, inside the
window the old one would have had anyway. There is no dual-key verify: it would keep the old
coupling alive.
The inputRequired round trip, on stdio too
Section titled “The inputRequired round trip, on stdio too”A client that advertises the MCP elicitation capability (elicitation/create,
SEP-2260/2322) gets the confirmation as an actual protocol round trip — the server
answers inputRequired({ requestState, inputRequests: { confirm: … } }), and the
call completes once the human answers accept with approve: true. This works on
both stdio and HTTP, and on both protocol eras:
- On a MODERN (2026-07-28) connection, the client drives the round trip itself:
it shows the form, resubmits the call echoing back
requestStateand the answer, and the server verifies both before trusting them. - On a LEGACY (2025-11-25/2025-06-18) connection, the underlying MCP SDK’s
low-level
Serverfulfils the round trip server-side: it sends theelicitation/createrequest itself, waits for the answer, and re-enters the handler with a verifiedrequestState— the client sees nothing different from the modern case except the wire shape. This legacy path is opt-in and stdio-only (legacyElicitationShim); Streamable HTTP never takes it, since a server-initiated request over stateless HTTP is unverified against real clients and out of scope. A legacy-era HTTP session behaves exactly as it did before this mechanism existed: the plainelicit_requirederror below.
Either way, nothing but accept with approve: true is an approval, and the call is
never prompted a second time (a verified requestState alone proves the confirmation
is authentic, never that it was granted — the server also checks the elicitation’s
own answer before trusting it). The two non-approving answers are told apart, as the
MCP specification defines them: decline is “User explicitly declined the request”,
cancel is “User dismissed without making an explicit choice”.
decline(oracceptwithapprove: false) is a hard stop. The error’sdetails.reasonisapproval_declinedand its text says the user declined: do not retry, do not mint a token. The server stops the call at the decline and does not dispatch it again, so a gate that stopped applying while the prompt was open (an overwrite target removed meanwhile) cannot let the refused change run.cancelmeans approval was not obtained; nobody said no. The write is still refused, but the error’sdetails.reasonisapproval_not_obtainedand both its text andrecoverypoint at the out-of-bandobsidian-tc elicitroute below. This matters for headless clients: Claude Code run non-interactively advertises elicitation and auto-answerscancelwithin milliseconds, with no human in the loop, so the operator approves out of band instead. Codex run non-interactively (codex exec) answersdeclineinstead, which is the hard stop above: the agent cannot recover, so the user approves by minting the token themselves (see MCP clients).
The single-use elicit-token/CLI mechanism below is what a client with no elicitation capability, or one that only ever cancels, falls back to.
A client that declares a bare elicitation: {} capability (no form/url sub-key)
is treated as supporting form mode — the 2025 spec’s pre-mode default — not as
declining elicitation; this applies uniformly, on every transport and era.
When your client can’t render the prompt
Section titled “When your client can’t render the prompt”A client with NO elicitation capability at all (or one that auto-cancels the prompt)
gets no round trip to complete: a call to
one of the 16 conditionally-gated tools (move_note across a folder boundary,
delete_note, restore_note, prune_hub_links, and others) simply fails with
an elicit_required error and no round trip to complete it. The error text itself
leads with a directive telling the AGENT to ask the human before running anything —
it is not an instruction the agent should act on unilaterally:
{ "code": "elicit_required", "details": { "args_hash": "…" } }There is no way to weaken this gate, and no reason to route around it by editing the
vault directly — doing so bypasses the audit trail, the folder ACL, and (for delete_note)
the snapshot restore_note depends on, all at once. Instead, mint the confirmation token
from the command line:
obsidian-tc elicit --hash <args_hash> --tool <tool_name> [--vault <id>] [--caller <id>] [--state-fp <fp>]--hashis theargs_hashtheelicit_requirederror’sdetailscarried.--toolnames the tool the confirmation is for (recorded for audit; the binding itself is the args_hash, which already encodes the tool name).--vaultis required when the config lists more than one vault.--callerdefaults to"stdio", the identity every locally-spawned MCP client presents over the trusted stdio transport (obsidian-tc serve <vault>) — the common case this command exists for. On an HTTP/jwtdeployment, pass the same value given totoken mint --sub.--state-fpis thestate_fptheelicit_requirederror’sdetailscarried (the rendered command already includes it). It pins the token to the state that request was raised against. Repeating a blocked call after its target changed records a newer fingerprint under the same args_hash, so a command copied from the earlier refusal would otherwise approve the newer state; with the flag the mint is refused unless the flag names the retained request’s own fingerprint (one that was never stored, or that a newer request replaced, is rejected).
The command prints the token, and nothing else, to stdout; send the same call again with
elicit_token: <token> and it proceeds. The token carries every property the mechanism
above requires: it is bound to the exact vault, args_hash, and caller given (a token minted
for one call is refused for a different one), single-use, and expires after the configured
elicitTtlSeconds — there is no --ttl flag, so a mint can never outlive what the live
server itself would have issued.
The caller id is visible. The refusal’s text and its structured recovery spell out the
concrete command, including --caller <id>: the calling principal’s id (the JWT subject on
HTTP, stdio on stdio) therefore appears in the model-visible error and, once the command is
pasted, in shell history. It is the same principal that made the call and already sits in the
audit log; it is not another caller’s identity.
Authorization. Minting requires opening the same cache.db the live server reads
elicit_tokens from — filesystem access to the vault’s cache directory. That directory
already holds auth.jwtSecret and every configured provider API key; token mint already
treats read access to it as sufficient authority to issue a bearer credential with wildcard
scopes. This command asks for nothing stronger: whoever can read that directory could
already run obsidian-tc serve against it and dispatch the exact call being gated, with no
confirmation at all. There is deliberately no MCP tool for this — a tool would let the model
under the gate clear its own gate; the CLI keeps it a human-operator action.
The response governor
Section titled “The response governor”A shared governor caps the byte size of any single tool response
(governor.maxResponseBytes). When a result would exceed the cap the call is
refused with an overflow error (rather than returning an unbounded payload);
the refusal is counted (governor_truncations_total) and emitted as
tc.governor.overflow. This bounds memory and protects clients from
pathologically large payloads.
read_notes is the exception to that refusal: it stays under the cap by paging. It returns the
notes that fit plus a next_cursor, and the caller repeats the same request with cursor set until
next_cursor is null. Every page makes progress; a note that could never fit is reported as a
per-path too_large error (with its size and the budget) and skipped. The cursor is HMAC-signed
and bound to the caller, the tool and the request arguments, expires after ten minutes, and is
refused as invalid_input (details.reason is expired, invalid, foreign or
request_mismatch) if replayed by another caller or against a different request. The folder ACL is
re-checked per note on every page, so a path revoked mid-walk is not served.
An MCP resources/read honors the same configured governor.maxResponseBytes ceiling
— lowering it refuses an oversized resource too, not just an oversized tool
response. A resource’s rejection is a plain invalid_input error (checked via a cheap
stat() before the file is read, rather than serializing the result first), so it does
not increment governor_truncations_total or emit tc.governor.overflow — those stay
specific to the tool-dispatch governor stage.
See Observability for the counters these emit.