Skip to content

Provide tools to interact_with_*

The included interact_with_llm action routes a conversation to Claude, Claude Code, Codex, Gemini, GPT, Ollama, or a vLLM deployment. Its tools input is a stream of provider-neutral tool definitions. Bind the same registry to the action so requested names can be resolved and run.

The handler owns the repeated model/tool loop: send definitions, receive tool calls, run them, encode one result for every call, and ask the model to continue. The application supplies policy and handlers without reconstructing the different message shapes expected by each provider.

Prepare the model action

import asyncio
import os

import a11
from a11.sdk.interact_with_llm import (
    INTERACT_WITH_LLM_SCHEMA,
    interact_with_llm,
)
from a11.sdk.llm import LlmHeaders
from a11.sdk.llm_tools import runner

allowed = ["look_up_order"]
interact = (
    a11.Action(INTERACT_WITH_LLM_SCHEMA)
    .bind_handler(interact_with_llm)
    .bind_registry(registry)  # Contains LOOK_UP_ORDER and its handler.
    .set_header(LlmHeaders.PROVIDER.value, "gemini")
    .set_header(LlmHeaders.MODEL.value, "gemini-3.5-flash")
    .set_header(LlmHeaders.API_KEY.value, os.environ["GEMINI_API_KEY"])
    .set_header(LlmHeaders.ALLOWED_LLM_ACTIONS.value, ",".join(allowed))
    .run()
)

tool_definitions = runner.get_tool_definitions(registry, allowed)

Feed the turn and tools

interactions = interact["interactions"]
for previous in history:
    await interactions.put(previous)
await interactions.finalize(question)

await interact["config"].finalize()

tools = interact["tools"]
for definition in tool_definitions:
    await tools.put(definition)
await tools.finalize()

The handler sends those definitions to the chosen provider. If the model calls one or more, the included runner executes the calls independently, returns each result in the provider's expected shape, and continues until the model produces an answer or the deadline ends. One failed call is reported without discarding successful calls from the same response.

Sending the definitions is optional for actions the handler's own registry already holds. Before the request, the handler collects the turn's tools with runner.collect_tools: the definitions on the tools port that the allow-list matches, plus every registered action name it matches that the caller did not describe. So a remote caller that sets x-a11-allowed-llm-actions to shell_.* is offered the server's shell tools without having to reproduce their schemas, and a caller that does not is not offered them at all — the allow-list is both the permission and the request.

Read visible text as it streams, and retain completed interactions separately:

async def print_answer() -> None:
    async for text in interact["text_output"]:
        print(text, end="", flush=True)

print_task = asyncio.create_task(print_answer())
new_interactions = [item async for item in interact["new_interactions"]]
await print_task
await interact.wait()

# Persist both sides of the turn for the next request.
history.extend([question, *new_interactions])

The backend-specific actions (interact_with_claude, interact_with_claude_code, interact_with_codex, interact_with_gemini, interact_with_gpt, interact_with_ollama, and interact_with_vllm) expose the same tools port and registry pattern when direct provider control is preferable. The routing action provides one application boundary: switching providers requires only a header change and leaves the conversation flow unchanged.

interact_with_claude_code reaches the same allow-list and registry through a different mechanism: Claude Code runs the agent loop, and the handler publishes each admitted action to it as an in-process MCP server tool. A call still arrives at runner.execute_actions_from_interaction, so failures, logs, and result encoding match the other backends.