Elicitation
Elicitation is the MCP feature where a server pauses and asks the user something — “which environment?”, “confirm this deletion”. The agent process stays alive and blocked while the human answers.
This is genuinely hard in an orchestrator, because the human isn’t at a terminal. Zimmer’s answer:
surface the question as a banner in the web UI, flip the session to needs_input so it lands on
your homepage, and let the MCP server poll for your answer over HTTP.
The round trip
Section titled “The round trip”The key insight in the design: block_on_elicitation deliberately does not call
cleanup_running_job. A normal pause tears down the agent process. Doing that here would
break the round-trip — the MCP server would poll forever into a corpse. So the session shows as
needs_input (for your attention queue and notifications) while the process stays alive.
Statuses
Section titled “Statuses”pending → accept | decline | cancel | expired.
accept and decline answer the question. cancel is the protocol’s “dismissed without
answering” — the Dismiss button on the banner, action_type: "cancel" on
PATCH …/respond, and "cancel" on the respond_to_elicitation MCP tool. It ends the
round-trip with an outcome the polling server can read, instead of leaving a request nobody
intends to answer to sit out its full window. expired is the clock answering instead of a
person.
Only an accept carries content. Anything sent with a decline or a cancel is dropped rather
than stored and later replayed to the MCP server as if it were an answer.
Expiry
Section titled “Expiry”Three sources, highest precedence first:
| Source | Set by | Scope |
|---|---|---|
_meta["com.pulsemcp/expires-at"] | the MCP server, per request | that one request |
ELICITATION_EXPIRATION_MINUTES | the operator, in the deploy environment | this Zimmer instance |
Elicitation::DEFAULT_EXPIRATION (60 minutes) | shipped default | fallback |
An MCP server that names its own deadline keeps it — it is the one party that knows how long its
call can stay open. Everything else gets the instance default. A blank
ELICITATION_EXPIRATION_MINUTES is treated as unset; a non-numeric or zero/negative one is logged
and ignored. A deploy never fails over this knob.
Every deadline, whoever names it, is held to MIN_EXPIRATION…MAX_EXPIRATION (1 minute … 7 days).
That bounds the MCP server’s own expires-at too, because it arrives on an unauthenticated
endpoint: a timestamp already in the past would mint an elicitation that is born expired — one
that resolves straight into the “this approval request expired” banner on a session the caller does
not own — and one years out would pin a session in needs_input.
The default is an hour, not the ten minutes it used to be: the feature exists to tolerate a human who is away from the desk, and a ten-minute fuse failed exactly the case it was for.
The default is applied on the model (before_validation), not only in the API controller, so an
elicitation created from anywhere gets a deadline. One with no expires_at at all is invisible to
both the active and the expired_pending scope — it would never block its session and nothing
would ever expire it.
Expiry happens two ways: lazily, on each poll (expire_if_needed!), and via
CleanupExpiredElicitationsJob every 5 minutes.
When a round-trip ends without an answer
Section titled “When a round-trip ends without an answer”Two ways an approval request ends with nobody having decided, and both now say so on the session page rather than leaving it looking merely idle.
Expired. The clock ran out. The MCP server’s next poll is answered expired, so the agent does
get an answer of a kind and the session flips back to running. Zimmer records
metadata["lost_elicitation"] with reason expired, which the session page renders as a banner:
nobody answered, and the agent continued without approval.
Stranded. The blocked_on_elicitation marker outlived its elicitation entirely. This happens
when a swallowed AASM::InvalidTransition (a state race) skips the after block that would have
cleared the marker, or when the MCP server crashes or is killed mid-round-trip so no resolve or
expire commit ever fires. CleanupExpiredElicitationsJob calls clear_stale_elicitation_block!
every 5 minutes to restore the invariant “marker set ⇒ an active elicitation exists”.
It strips the marker but leaves the session in needs_input — flipping a minutes-stale block back
to running would create a phantom running session with no monitoring job. What used to be missing
is the explanation: a session parked in needs_input with the banner gone and nothing to say why
is indistinguishable from one idling after a normal turn. So a stranded needs_input session also
gets metadata["lost_elicitation"] with reason stranded, and the page says the round-trip was
lost and the session is no longer blocked on it. A session that the sweep finds running (the
swallowed-transition case) gets the marker cleared and no banner — its agent never stopped, so
there is nothing for you to act on.
The marker is dropped the moment the session moves on: a resume, a new elicitation, or an elicitation that actually gets answered.
Known problems
Section titled “Known problems”Where the request goes, and what happens when it can’t get there
Section titled “Where the request goes, and what happens when it can’t get there”Five variables carry the address and the decision to use it:
| Variable | Value |
|---|---|
ELICITATION_REQUEST_URL | <AppUrl.base_url>/api/v1/elicitations |
ELICITATION_POLL_URL | the same collection URL — the client appends /<request-id> |
ELICITATION_PREFER_HTTP_FALLBACK | true |
ELICITATION_TTL_MS | Elicitation.default_expiration in milliseconds |
ELICITATION_SESSION_ID | the Zimmer session id |
The last three are not decoration, and leaving any of them to the client’s default breaks the round trip in a way that looks exactly like a denial:
ELICITATION_POLL_URL.@pulsemcp/mcp-elicitationdecides whether the HTTP fallback tier exists at all withBoolean(requestUrl && pollUrl), before it has made the POST that would have told it the poll URL. A request URL on its own leaves the whole tier invisible.ELICITATION_PREFER_HTTP_FALLBACK. The client’s default order tries native MCP elicitation first and only then the HTTP fallback. Headless Claude Code advertises the elicitation capability with no human attached to answer, so the server asks the agent, the agent declines in milliseconds, and Zimmer is never asked. The library documents this flag for exactly that case — a runtime that “falsely advertises elicitation capability but cannot actually surface the prompt to a user.”ELICITATION_TTL_MS. The client sends its owncom.pulsemcp/expires-at, which outranks Zimmer’s default by design, and its built-in TTL is five minutes. That is a fuse measured in minutes, which fails precisely the away-from-the-desk case the hour-long default exists for.
All three failure modes end the same way: the MCP server returns [REDACTED] and the agent, quite
reasonably, reports that the request was declined — when in fact nobody was ever asked.
They are written in two places, because a stdio MCP server gets its environment two different ways:
CliSpawnEnv#apply_elicitation_envputs them on the agent CLI process. Claude Code hands a stdio server its own environment, so that reaches the server there.RuntimeConfigPostProcessor#inject_elicitation_env!writes them into each stdio server’s ownenvtable in the generated MCP config (.mcp.json/.codex/config.toml), atair preparetime. This is the only channel Codex honors: it rebuilds a server’s environment fromHOME/LANG/PATH/PWD/SHELLplus whatever the entry’s ownenv/env_varsname. Measured on codex-cli 0.146.0, a stub stdio server spawned bycodex execfrom a shell where both variables were set received neither — so before this existed, every Codex approval POST went to the client’s baked-inhttp://zimmer/…default and died asfetch failed.
A value already present in the session’s .env wins in both places, so an operator can point a
server at a different Zimmer.
Zimmer’s value beats a catalog entry’s own env for these five keys, and only these five. The
address of Zimmer’s own endpoint is Zimmer’s to know; a copy in mcp.json is a duplicate that
goes stale without anything failing loudly — which is exactly what happened, a http://zimmer
left in a catalog entry shadowing the injected URL for months. Everything else in the entry’s
env is merged around, never replaced: that table is where a server’s credentials live.
One variable is deliberately not set: ELICITATION_ENABLED, because whether a server gates a
given action is that server’s decision. The reported failure was the address, not the enablement,
and forcing it on would newly block sessions on approvals across every server at once.
The poll URL used to be on that list, on the reasoning that the create response carries
_meta["com.pulsemcp/poll-url"] and so the poll URL follows the request URL automatically. It does
— but only for a client that has already chosen the HTTP tier, and the client tests requestUrl && pollUrl to decide whether that tier is available in the first place. The response could never
arrive to fix an absence that stopped the request being made.
Naming the request URL is not cosmetic. With only ELICITATION_SESSION_ID set — which is all Zimmer
used to set, and only for Claude — the @pulsemcp/mcp-elicitation client fell back to its built-in
default, http://zimmer/api/v1/elicitations. That is a Tailscale MagicDNS name: it resolves on the
host, and not in the container agents run in. Every POST failed at connect, the client fell back to
“not approved”, and the server returned [REDACTED]. From the agent’s side that is indistinguishable
from a denial — a gate that fails closed and fails silently, which is how one session ended up
reading the secret it needed through the service account instead.
ElicitationEndpointHealthCheckJob probes the endpoint every 5 minutes from the host agents run on
(any HTTP response counts — a 404 for the probe id proves the request reached Rails; only a transport
failure is a broken gate) and records the result. It runs in production and staging;
not in development,
where the URL it would probe describes your own laptop rather than anything agents depend on. When
the endpoint is unreachable, the job warns on every tick and pages once per incident, and OrchestratorSystemPromptBuilder puts the failure in the system prompt of every session
spawned while it is down: the gate is broken, a redaction means nothing about policy, report it rather than
routing around it. Sessions with MCP servers always get the healthy-case counterpart — a redacted
value is the gate’s answer — so a redaction is never ambiguous.