fix(ask-user): key AskUserQuestion answers by question text for SDK schema#359
Closed
tusharsingh wants to merge 33 commits into
Closed
fix(ask-user): key AskUserQuestion answers by question text for SDK schema#359tusharsingh wants to merge 33 commits into
tusharsingh wants to merge 33 commits into
Conversation
…tput
The canUseTool path forwarded the UI's numeric-index answers map
({"0": "...", "1": "..."}) straight to the SDK as updatedInput.answers.
The Agent SDK's AskUserQuestionOutput schema keys answers by question
text, so the SDK could not match any answer back to its question and
the model received blank answers — visible as "I don't see an answer"
or no acknowledgement of the user's selection.
Remap numeric-index answers to {questionText: answer} before resolving
the canUseTool promise. The MCP/mates path is unaffected: it builds a
plain-text user message and never touches the SDK answers schema.
…on-text fix(ask-user): key answers by question text for SDK AskUserQuestionOutput
…estart session.titleManuallySet (set on rename_session) and titleAutoGenerated (set by the auto-title pass) gate the AUTO_TITLE_TURN_THRESHOLD check in sdk-message-processor.js, but neither flag was serialized in saveSessionFile / loadSessions. After any daemon restart the flags were lost while session.title persisted, and the next time the session crossed turn 2 the auto-title overwrote the user's rename. Persist both flags in the session meta and restore them on load. Add a backwards-compat shim so sessions whose files predate this change but already carry a non-default title also skip the next auto-title pass.
…name fix(sessions): persist title-origin flags so manual renames survive restart
A single tool, spawn_session, lets an agent in a Clay session create new sessions in the current project and seed each with an initial user message. Returns immediately so the caller can fan out multiple sessions in one turn — primary use case is splitting a planning session's list of work items (e.g. JIRA issues) into per-item sessions, each started with the appropriate slash command (e.g. "/jira HARD-207"). Slash commands pass through verbatim to the SDK. The new session's title is marked titleManuallySet=true so Clay's auto-title pass at AUTO_TITLE_TURN_THRESHOLD does not overwrite it. Available in main project sessions (not mates), matching the scope of clay-debate. Inputs: title, initial_prompt, optional vendor (defaults to "claude").
Beef up the tool description so the agent calls spawn_session on its own when the user says things like "start working on these in clay", "split these into sessions", or "open a session for each of these" — instead of requiring the user to point at the tool by name. Adds explicit trigger phrases, behavior guidance (call in parallel, no confirmation prompt, brief post-spawn summary), title-selection rules (use the item identifier), and initial-prompt selection (prefer a project slash command like "/jira <KEY>" when one fits, fall back to a short natural-language instruction).
When the user said "Start working on these issues in phase 1 in clay", the agent picked the clay-ralph skill (which claims phrases like "I want to automate this task" or "run overnight") instead of spawn_session. Tighten the tool description so the agent reliably picks spawn_session for any "list of items → list of sessions" request: - Call out clay-ralph by name in a DISAMBIGUATION block: ralph is for a single autonomous loop against ONE prompt/judge pair; spawn_session is for splitting a list of work ITEMS into per-item interactive sessions. - Add explicit triggers for "work on these in clay", "do these in clay", "one session per issue", and "phase 1/2/..." phrasings. - Restrict clay-ralph to explicit "ralph"/"autonomous loop"/"AFK" cues.
The clay-tools stdio bridge at lib/yoke/mcp-bridge-server.js was built
for Codex; reuse it to surface Clay's in-app MCP servers (clay-sessions,
clay-debate, clay-history, clay-ask-user) inside TUI Claude sessions
too. Without this, a TUI session only sees file-based skills like
/clay-ralph — MCP tools were silently absent because Clay never wired
its servers into the claude CLI's MCP config.
Changes:
- lib/tui-mcp-config.js: helper that writes a temp --mcp-config JSON
pointing at the clay-tools bridge for the daemon's port/slug/auth,
with a matching cleanup hook. Shared by all TUI launch paths.
- lib/project-sessions.js: both TUI launch sites (new-TUI-session and
runtime-resume PTY) now append --mcp-config and remove the temp file
on PTY exit.
- lib/spawn-mcp-server.js: spawn_session accepts mode ("tui" | "gui"),
defaulting to "tui" so per-issue sessions inherit subscription billing.
Tool description gates "gui" mode to explicit user requests or codex.
- lib/project.js: onSpawn callback branches on mode. TUI spawn
pre-assigns the cliSessionId and launches
claude --session-id <uuid> -n "<title>" --mcp-config <cfg> "<prompt>"
in an xterm via tm.create. GUI spawn keeps the existing SDK flow.
When spawn_session created a TUI session, it launched the claude CLI
with no attached client. claude exited almost immediately ("no
interactive partner"), the PTY closed, onExit fired, and the session
was deleted — making the spawned session appear in the sidebar for a
few seconds and then disappear.
Defer the PTY launch. spawn_session now creates the session record
with mode=tui, cliSessionId preassigned, and pendingInitialPrompt
stored. The PTY is launched in project-sessions.js switch_session when
the user first opens the session, with the active ws as the PTY owner.
By that point there is an attached client, claude stays interactive,
and the conversation runs normally.
Also derive the session title from the leading slash command's first
positional arg (e.g. "/jira GP-222" -> "GP-222") so the sidebar title
matches the issue identifier regardless of what the calling agent
passed. Falls back to the agent-supplied title for free-form prompts.
Persist pendingInitialPrompt across daemon restarts so a spawned
session that the user opens after a restart still seeds claude with
the right first message.
On PTY exit, no longer delete the session: the conversation is saved
under ~/.claude/projects/<cwd>/<uuid>.jsonl and the user can re-open.
Three gaps left TUI sessions invisible (or dead) after a daemon restart: 1. lib/project-sessions.js new_session TUI path created the session via createSessionRaw + switchSession but never called saveSessionFile, so no record was written to ~/.clay/sessions/<slug>/<uuid>.jsonl and loadSessions had nothing to bring back. 2. saveSessionFile/loadSessions did not include session.mode in the meta block, so any TUI session that did get persisted (e.g. via the spawn flow or via a later save) came back tagged as gui. The deferred-launch check in switch_session keys off mode === "tui" and would silently skip the relaunch. 3. Even with mode restored, a freshly loaded TUI session has no in-memory terminalId (terminals are not persisted). The previous deferred-launch block only handled the spawn-first-open case (pendingInitialPrompt). Post-restart sessions have no pending prompt but still need a PTY. Changes: - lib/sessions.js: meta.mode = "tui" written/read; mode is the only non-default value worth persisting (gui is implicit). - lib/project-sessions.js: saveSessionFile after TUI new_session creation so the record hits disk. - lib/project-sessions.js switch_session: the deferred-launch block now branches — pendingInitialPrompt -> claude --session-id <uuid> with the prompt; otherwise -> claude --resume <uuid>. Either way the PTY only launches when the user actually opens the session, with an attached ws, so claude stays interactive.
claude's --mcp-config flag is variadic (<configs...>) so it greedily
consumes following non-flag args. A spawn_session command like
claude --session-id <uuid> -n 'GP-222' \
--mcp-config '/tmp/clay-tui-mcp-xxx.json' '/jira GP-222'
was being parsed as two MCP config files. The CLI rejected the launch
with "MCP config file not found: /jira GP-222".
Insert "--" between the options list and the positional prompt so the
prompt isn't swept into --mcp-config.
Defer-until-click was a workaround for sessions disappearing when their PTY exited immediately — but that root cause was the variadic --mcp-config swallowing the positional prompt (fixed in c73ccea), not "no client attached". Restore eager launch so spawn_session actually fans out work in parallel: the user calls the tool, claude starts running /jira <KEY> in each spawned session in the background, and the user can come back later to see results. The deferred-launch path in switch_session still handles the post-exit re-open case (terminalId cleared on PTY exit, next click spawns `claude --resume <uuid>`), so subsequent opens resume the persisted conversation rather than starting a new one.
The agent's title arg is now overridden by the first positional argument of any leading slash command (e.g. \"/jira GP-222\" -> the title becomes \"GP-222\"). Document this in the tool description so the agent passes the right shape and isn't surprised when its title arg gets replaced.
Add permission_mode and effort args to spawn_session. Both default
toward planning-shaped per-issue work:
- permission_mode defaults to "plan" so each spawned session loads
context with the slash command, produces a plan, and waits for the
user before editing.
- effort defaults to "high" so the planning pass reasons carefully.
Wired into the TUI launch as --permission-mode <mode> --effort <level>.
GUI sessions don't honor these args yet (the SDK adapter reads effort
and permission mode from per-session state at startQuery time and there
is no clean hook from the MCP onSpawn handler); spawned GUI sessions
inherit the daemon-level defaults for now.
The tool description for both args calls out the planning-shaped
default and the override path, so non-planning spawns can opt out.
spawn_session previously returned a free-text "Spawned session #N \"T\""
line, which forced the dispatcher agent to parse the localId out of
prose and gave it no way to refer back to the spawned session's claude
UUID. Two consequences:
- No clean handle to pass to future tools like mark_session_done,
send_to_session, or any other operate-on-session helper.
- No claude --resume target the dispatcher could surface to the user
("session #5 is GP-222, resume from the terminal with claude
--resume <uuid>").
Return an additional content block per call:
[clay-sessions/spawn_session] localId=42 cliSessionId=50a893... \
title="GP-222" mode=tui vendor=claude
The summary line is kept for human-readable rendering. The onSpawn
callback's resolved value now includes cliSessionId, mode, and vendor
so the MCP wrapper can render the tracking line without re-deriving.
TUI sessions have cliSessionId from spawn-time (we preassign); GUI
sessions report cliSessionId=(pending) until the SDK emits init and
sm.saveSessionFile updates the record.
Companion to spawn_session for closing out work. The user's /done skill (or any agent) calls mcp__clay-sessions__mark_session_done with an optional session_id; Clay prefixes the matching session's title with "done - " (idempotent) and broadcasts the sidebar update so the user sees the session visually marked. - session_id omitted: operates on the currently active session (the natural mode for /done invoked from inside the session being closed). - session_id from a dispatcher's tracked spawn_session call: closes a peer session without switching to it. - undo: true reverses the prefix to recover the original title. Title-origin flags carry over: titleManuallySet is set so Clay's auto-title pass cannot overwrite the marker. SDK rename is broadcast to claude so the prompt-box display name (-n) stays in sync. Tool result mirrors spawn_session's shape: a human-readable summary plus a [clay-sessions/mark_session_done] tracking line carrying localId / cliSessionId / new title / action / changed. The dispatcher keeps a structured handle to refer back to the affected session.
feat(sessions): spawn_session MCP tool + TUI MCP wiring
…P bridge Surfaces the new spawn_session / mark_session_done workflow and the TUI MCP bridge so users can find them without reading the source: - Top-level "What's Clay?" bullet now mentions fan-out alongside Ralph Loop and cron. - New "Session fan-out: one prompt, many sessions" section with the literal trigger sentence, the per-ticket title + auto-loaded /jira context + plan-mode-at-high-effort behaviour, and the /done close-out flow that flips JIRA status and prefixes the Clay sidebar entry. - MCP FAQ updated to list the new "sessions" built-in server and the TUI bridge that exposes Clay's in-app servers to the real claude CLI via --mcp-config. - Architecture diagram's Built-in MCP servers node updated to include "sessions" next to ask-user / browser / debate / email.
The TUI session host computed its bottom edge from window.innerHeight, which iOS Safari (and the PWA standalone runtime) leaves at the full viewport height even while the on-screen keyboard is up. The lower portion of the xterm slid behind the keyboard with no way to bring it back without dismissing input. Switch the bottom-edge calculation to window.visualViewport.offsetTop + visualViewport.height, which tracks the visible area above the keyboard and accounts for any layout shift iOS applies. Subscribe the view to visualViewport's resize and scroll events too — neither show up on the regular window resize listener — so the host re-fits and fitAddon recomputes cols/rows when the keyboard slides in or out.
…ive one /done from a TUI session marked the WRONG session done when the user switched sessions while claude was mid-/done (e.g. waiting on a JIRA transition). mark_session_done's fallback was sm.getActiveSession(), which returns whichever session the user is currently viewing — so by the time the tool fired, "active" had moved to the user's new session and got the "done - " prefix. Plumb the calling session's cliSessionId from the TUI bridge through to the MCP tool handler so it's deterministic: - lib/yoke/mcp-bridge-server.js: accept --session-id <uuid>, include it as callingCliSessionId in every call_tool POST body. - lib/tui-mcp-config.js: buildTuiMcpConfig now accepts a cliSessionId arg and adds --session-id to the bridge launch args. - lib/project-sessions.js + lib/project.js: all four TUI launch sites (new TUI session, runtime resume PTY, switch_session deferred re-open, spawn_session) pass the session's cliSessionId through. - lib/project-http.js: /api/mcp-bridge call_tool extracts callingCliSessionId from the body and injects it into the tool args as __callingCliSessionId. - lib/sessions.js: new findSessionByCliSessionId() helper. - lib/project.js onMarkDone: resolves the target in priority order — explicit session_id > __callingCliSessionId lookup > getActiveSession. GUI MCP tool calls don't go through the bridge, so they still fall back to getActiveSession; that path doesn't have the same async race because GUI tool calls are in-process and the session is identifiable from the SDK invocation context. A future change can plumb the calling session into GUI tool handlers too.
Two ways to hand a file's server-side path to the TUI's claude CLI:
- Drag-and-drop onto the xterm host (desktop). A dashed outline
appears while a file is dragged over.
- A floating "+" button at the bottom-right of the host, sized for
touch. On iOS it opens the native picker which offers both Photo
Library and Files; on Android the equivalent picker.
Both paths read each selected/dropped file as base64, POST it to the
existing /api/upload endpoint (which writes it to /tmp/clay-<hash>/
and returns an absolute path on the server), then send the
space-separated paths to the PTY as a single term_input frame. The
paths arrive at claude's prompt exactly as if the user had typed
them, so they append to whatever the user was composing and can be
referenced (e.g. claude Read, attach as image) per its usual rules.
Drag and drop events aren't fired on touch devices, so mobile uses
the + button only. The button sits inside hostEl, which is sized
above the iOS keyboard via visualViewport, so it stays reachable
when the keyboard is up.
Uploads run in parallel; paths are batched into one PTY write so
they appear at one cursor position rather than racing as each
upload completes.
spawn_session derives the session title from the leading slash command's
first positional arg ("/jira GP-222" -> "GP-222"), which keeps the
sidebar clean but loses context. A skill that has just fetched the
issue summary now has a way to refine the title to something like
"GP-222 - Implement OAuth refresh".
rename_session({title, session_id?}) sets the title on a session.
session_id is optional; omitted it routes to the calling session via
the same __callingCliSessionId path mark_session_done already uses,
so the rename always hits the session that invoked the tool — not
the user's globally-active view.
Refactored onMarkDone to share resolveTargetSession() with the new
onRename so both apply the same priority: explicit session_id >
__callingCliSessionId > getActiveSession().
The new title is marked titleManuallySet=true so Clay's auto-title
pass at AUTO_TITLE_TURN_THRESHOLD doesn't overwrite it; the SDK
rename is broadcast so the claude prompt-box display name stays in
sync.
Now that claudeOpenMode defaults to tui and spawn_session creates TUI sessions by default, the "terminal" badge appeared on basically every sidebar row and stopped carrying signal. Removing it matches what the session list already implicitly conveys. The reload-then-icon-disappears asymmetry the user noticed (mode wasn't persisted before, then was, so badges came and went across restarts) also goes away with the badge.
…ions show The Import CLI picker called adapter.listSessions(cwd) and only fell back to the FS-based parser when the SDK call threw. In practice the SDK's listSessions returns a strict subset of what's on disk for some projects — conversations claude --resume happily finds via direct .jsonl scan never appeared in Clay's Import CLI even though they weren't tracked by Clay yet. Users couldn't bring orphaned claude sessions back into Clay's sidebar. Union the two sources: always run cli-sessions.listCliSessions (which parses every .jsonl in ~/.claude/projects/<encoded-cwd>/), then overlay richer metadata from the SDK where present. The filter that hides sessions already known to Clay still runs. After this change, anything `claude --resume` can resume is reachable from Clay's Import CLI too. In particular: spawn-created TUI sessions whose Clay record was wiped by older deletion-on-exit code (before 7d0c006) can be recovered by clicking them in Import CLI.
Default behaviour for Import CLI is back to the original — list the sessions the Agent SDK enumerates for this cwd, hiding any UUIDs Clay already tracks. Quiet, fast. Add a "Show all" checkbox in the picker that, when checked, also scans the filesystem at ~/.claude/projects/<encoded-cwd>/ and unions the results. That surfaces conversations the SDK doesn't list — typically orphaned sessions (e.g. TUI records wiped by pre-7d0c006 deletion-on-exit code) — so the user can recover them. Server: list_cli_sessions accepts an optional show_all flag and runs the filesystem scan only when true. Client: HTML checkbox, JS that re-fires list_cli_sessions on toggle, small CSS to host the new controls row.
TUI sessions don't route their typing through Clay (claude writes the
jsonl to its own per-cwd store), so once the meta file was saved at
spawn time the sidebar timestamp never moved — a session created
yesterday and used heavily today still rendered as "yesterday".
Two new fresh-activity signals:
- switch_session: forced bump on open. Clicking into a session is a
strong "I'm using this now" signal regardless of whether the user
goes on to type. Forced persistence so the sidebar reorders right
away.
- term_input: bump per keystroke / chunk written to the TUI's PTY,
throttled to one meta-file write per 60 seconds per session so we
don't thrash disk on every byte.
sessions.js gets two helpers: findSessionByTerminalId (the term_input
handler doesn't otherwise know which session owns a terminal) and
bumpSessionActivity(localId, {force}) which centralises the throttle.
GUI sessions still update via the existing message-flow code paths;
this change only fixes the gap for TUI.
…current Bumping on session open meant a user just browsing through TUIs would mark them all "today" even though they did nothing. Per user request, revert that part — only PTY input (term_input) bumps lastActivity now. The 60s throttle in bumpSessionActivity still applies, so heavy typing doesn't thrash the meta jsonl. First keystroke after a long idle still forces a persist because _lastActivityPersistedAt is unset, giving immediate sidebar reorder once the user actually engages.
Replace the "done - " title prefix with a real boolean on the session
record. The sidebar now has Active and Completed tabs that partition
sessions by the flag; tag counts on each tab reflect what's hidden.
Backend:
- lib/sessions.js: persist meta.done in saveSessionFile, read it in
loadSessions, and migrate legacy "done - "-prefixed titles on load
(strip the prefix, set done=true). The migration runs only when
meta.done isn't already recorded, so it's idempotent.
- lib/sessions.js mapSessionForClient: include done in the broadcast
payload so the sidebar can filter without an extra round trip.
- lib/project.js onMarkDone: flip session.done instead of mutating
title. undo:true now clears the flag back to false.
- lib/spawn-mcp-server.js: tool description + summary updated to talk
about a flag, not a prefix.
Frontend:
- lib/public/modules/sidebar-sessions.js: module-level sessionListTab
("active" by default), renderSessionTabBar() with counts, filter
items by tab before partitioning into bookmarked/regular. Clicking
a tab re-renders. Loop groups always live in Active.
- lib/public/css/sidebar.css: tab bar styling (subdued chips, count
badge highlight on the active tab).
Existing sessions with "done - X" titles get auto-migrated to
done=true with the prefix stripped on next load — the user's accumulated
backlog of completed JIRA work shows up in Completed without manual
re-tagging.
Context menu now has an explicit toggle between Active and Completed
that doesn't require invoking the /done skill. Mirrors what
mark_session_done does from the MCP side: flips session.done, saves,
broadcasts. The label and icon swap based on the session's current
state ("Mark done" with a check / "Mark active" with a back-arrow).
Adds set_session_done to ws-schema as a c2s message.
There are two session_list construction paths: broadcastSessionList in sessions.js calls mapSessionForClient and includes all fields; the initial-connection sender in project-connection.js (lines 165-189) had its own hand-rolled payload that was a strict subset. The on-connect payload was missing the new "done" field, so after a restart the browser's first session_list arrived with done=undefined on every session and the new Active/Completed tabs ignored the migration. Subsequent broadcasts wouldn't fire until something mutated, so the user was stuck looking at everything in Active even though loadSessions had correctly set done=true on 227 sessions. Pull the missing fields (done, mode, vendor, terminalId, runtimeMode, runtimeTerminalId) into the on-connect payload so the initial render matches what later broadcasts contain.
Add a top-level skills/ directory with ready-to-install reference
implementations of the two Clay-flavoured slash commands the README's
"Session fan-out" section assumes:
- skills/jira.md: the full JIRA -> implementation workflow. Fetches
the issue, renames the Clay session to "<KEY> - <summary>",
transitions In Progress, dispatches parallel subagents for
codebase exploration, produces a plan with two approval gates,
implements, transitions to Done.
- skills/done.md: wraps up the session. Transitions the JIRA ticket
and calls mark_session_done, which flips the structural flag (no
longer prefixes the title) so the sidebar moves it from Active to
Completed automatically.
- skills/README.md: install instructions (user-level via
~/.claude/commands/ or project-level via .claude/commands/) plus
pointers to the Clay MCP tools the skills depend on.
Main README's Session fan-out section updated to:
- mention rename_session alongside spawn_session / mark_session_done
- describe the done flow as moving sessions to the Completed tab
(instead of the old "done - " title-prefix wording)
- link to skills/ as the install path.
The shipped /done text uses the new flag-based behaviour from
0233b75 and references the bridge --session-id routing from f6a621d.
The /jira skill's Step 8 transitions the JIRA ticket to Done but didn't touch Clay's sidebar, so after a clean /jira run the ticket showed Done in Atlassian while the Clay session sat in Active. Add a third sub-step that calls mark_session_done so the two endpoints stay in sync. Mirrors what /done does on its own — the difference is that /jira already has the issue key in context, so step ordering is "transition JIRA, mark Clay" rather than the standalone /done's "find key, transition JIRA, mark Clay".
When /jira launches in a freshly spawned session under plan mode at high effort, the agent was diving straight into exploration and folding the issue summary into its plan instead of printing the description block to the user as Step 2 requires. The user lost the "what does this ticket actually say" view they expect at the top of every spawn. Tighten Step 2: - Bold "Print the issue to the user before doing anything else." - Provide a concrete markdown template (heading with the key + summary, Type/Priority/Status row, Description body, Acceptance Criteria) so there's no ambiguity about layout. Add a matching Rules entry: - Step 2's block is non-skippable, must print before exploration, must print even when the model thinks the user "already saw" the issue. Both changes mirror to skills/jira.md and the local ~/.claude/commands/jira.md.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
For the Claude native
AskUserQuestionpath (canUseTool), Clay was forwarding the UI's numeric-indexanswersmapverbatim to the Agent SDK as
updatedInput.answers. The SDK'sAskUserQuestionOutputschema keysanswersbyquestion text, so the SDK couldn't match any answer back to its question and the model received blank answers —
surfacing as "I don't see an answer" or no acknowledgement of the user's selection.
Repro: trigger an
AskUserQuestionfrom any Claude-vendor session (mates are unaffected — see below), select or typeany answer, observe the model respond as if no answer was given.
Root cause
The UI in
lib/public/modules/tools.jsbuilds the answers object keyed by numeric index:The server then passed this straight through in
lib/project-sessions.js:But the Agent SDK's type for
AskUserQuestionOutput(@anthropic-ai/claude-agent-sdk/sdk-tools.d.ts) declares:So the SDK was looking up
answers["How should we handle X?"]and findingundefined— the model rendered a blank toolresult.
Fix
Remap numeric-index answers to
{questionText: answer}usingpending.input.questions[i].questionas the key beforeresolving the
canUseToolpromise. Falls back to"Question N"only if the question text is missing.What's not affected
The MCP/mates path (
lib/project.js→clay-ask-userMCP server) is untouched. It builds a plain-text user message viaformatAskUserAnswerAsMessageand never hands ananswersobject to the SDK's schema, so the mates flow has alwaysworked correctly.
Test plan
mainwith a 3-question card (one free-text "Other" answer, two option picks).dropping them due to the key mismatch.
ask_user_questionsflow still works (formatter is untouched but worth confirming).