Skip to content

Elicitation

The aifred_tk.elicitation module lets plugin tools (and LLM agents) ask the human user to select from a list of labelled choices at runtime.

Quick start

from aifred_tk.elicitation import Choice, elicit_choice

result = elicit_choice(
    "Which deployment target?",
    [
        Choice("staging", description="Pre-production environment"),
        Choice("production", description="Live environment", example="us-east-1"),
    ],
    allow_free_input=True,
)

if result.is_free_input:
    print(f"Custom answer: {result.free_text}")
else:
    print(f"Selected: {result.selected_label} (index {result.selected_index})")

Data model

Choice

Field Type Description
label str Short display text shown to the user.
description str \| None Optional sentence explaining the option.
example str \| None Optional concrete example (shown as "e.g. …").

ElicitationResult

Field Type Description
selected_index int 0-based index of the chosen option; -1 for free text.
selected_label str \| None Label of the chosen option; None for free text.
free_text str \| None User-supplied text when allow_free_input=True was picked.
is_free_input bool True iff the user typed a custom answer.

elicit_choice() API

def elicit_choice(
    question: str,
    choices: list[Choice],
    *,
    context: str | None = None,
    allow_free_input: bool = False,
    settings: Dynaconf | None = None,
) -> ElicitationResult: ...

elicit_choice automatically selects the right interaction mode:

Execution context Behaviour
Inside an MCP tool call Uses FastMCP ctx.elicit() — the MCP client (e.g. Claude Code) shows a native dialog.
CLI / plain Python, TTY stdout interactive mode — cursor-key selector via questionary.
CLI / plain Python, non-TTY stdout numbered mode — prints a numbered list, reads a number from input().

The mode can be overridden via configuration (see below).

Parameters

  • question — The question string displayed to the user.
  • choices — Ordered list of Choice objects; must not be empty.
  • context — Optional paragraph of context shown above the choices.
  • allow_free_input — When True, an "Other (specify…)" option is appended; if the user selects it, a follow-up prompt collects free text.
  • settings — Optional Dynaconf instance for reading the elicitation mode setting. Defaults to the global settings when omitted.

Errors

ElicitationCancelledError is raised when the MCP client explicitly declines or cancels the elicitation dialog. Terminal elicitors always return a result and never raise this error.

Configuration

Add to your settings file or environment:

# aifred-tk.yml
aifred_tk:
  elicitation:
    mode: auto   # auto | interactive | numbered
Value Description
auto Detects TTY: interactive if stdout is a terminal, numbered otherwise.
interactive Always use the cursor-key questionary selector.
numbered Always use the plain numbered-list input() selector.

Environment variable override: AIFRED_TK__ELICITATION__MODE=numbered.

MCP tool for LLM agents

When running as an MCP server, aifred_elicit_choice is also exposed as an MCP tool so that LLM agents (PydanticAI, LangChain, Claude, etc.) can ask the user a question on their own initiative — without needing a dedicated plugin tool as an intermediary.

Tool name: aifred_elicit_choice

Input schema:

Parameter Type Required Description
question string yes The question to display.
choices string[] yes Labels for each option.
descriptions string[] no Optional explanation per label (same order).
examples string[] no Optional example per label (same order).
context string no Optional context paragraph shown above the choices.
allow_free_input boolean no Append an "Other (specify…)" option.

Output schema:

{
  "selected_index": 0,
  "selected_label": "staging",
  "free_text": null
}

For free-text answers, selected_index is -1, selected_label is null, and free_text contains the user's input.

Using elicitation inside a plugin tool

Plugin tools run synchronously inside an anyio worker thread. elicit_choice bridges back to the async event loop automatically via anyio.from_thread.run(), so no special setup is required:

from aifred_tk.core.interfaces import Plugin, Tool, ToolArgs
from aifred_tk.elicitation import Choice, elicit_choice
from pydantic import BaseModel

class DeployArgs(ToolArgs):
    service: str

class DeployTool(Tool):
    tool_id = "deploy"
    description = "Deploy a service to a chosen environment."
    args_schema = DeployArgs

    def execute(self, args: DeployArgs) -> ToolResult:
        result = elicit_choice(
            f"Deploy {args.service} to which environment?",
            [Choice("staging"), Choice("production")],
        )
        return ToolResult(output={"environment": result.selected_label, "service": args.service})