Skip to content

Conversation

@dsfaccini
Copy link
Collaborator

@dsfaccini dsfaccini commented Nov 17, 2025

web-based chat interface for Pydantic AI agents

  1. new module pydantic_ai.ui.web
  2. new method Agent.to_web()

fastapi

  • app = create_chat_app(agent)

  • the following endpoints come preconfigured:

    • GET / and /:id - serve the chat UI
    • POST /api/chat - Main chat endpoint using VercelAIAdapter
    • GET /api/configure - Returns available models and builtin tools
    • GET /api/health - Health check
    • NOTE: I'm counting on FastAPI to complain if the user tried adding conflicting routes, otherwise we could add a warning on the respective docs.

options and example

NOTE: the module for options is currently pydantic_ai.ui.web.

  • pre-configured model options:

    • anthropic:claude-sonnet-4-5
    • openai-responses:gpt-5
    • google-gla:gemini-2.5-pro
  • supported builtin tools:

    • web_search
    • code_execution
    • image_generation
# app.py
import logfire
from pydantic_ai import Agent

logfire.configure(send_to_logfire='if-token-present')
logfire.instrument_pydantic_ai()

agent = Agent('openai:gpt-5')

@agent.tool
def get_weather(city: str) -> str:
    return f"The weather in {city} is sunny"

app = agent.to_web()

logfire.instrument_fastapi(app, capture_headers=True)

# Run with: uvicorn app:app

testing

  • 7 tests in tests/test_ui_web.py

notes

  • UI is served from CDN: @pydantic/[email protected]
  • Uses Vercel AI protocol for chat streaming
  • TODO: add clai web command to launch from the CLI (as in uvx pydantic-work without the whole URL magic)
  • TODO: should I add a new doc at docs/ui/to_web.md? I'd also reference this in docs/ui/overview.md and docs/agents.md

EDIT: if you try it out it's worth noting that the current hosted UI doesn't handle ErrorChunks, so you will get no spinner and no response when there's a model-level error and fastapi will return a 200 any way.
This will happen for instance when you use a model for which you don't have a valid API key in your environment
I opened a PR for the error chunks here pydantic/ai-chat-ui#4.

Closes #3295

args = parser.parse_args(args_list)

# Handle web subcommand
if args.command == 'web':
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it should be --web so it doesn't conflict with the prompt arg?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeahp, changed that now


self._get_toolset().apply(_set_sampling_model)

def to_web(self) -> Any:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're gonna need some args here -- have a look at the to_a2a and to_ag_ui methods. Not saying we need all of those args, but some may be useful

@dsfaccini
Copy link
Collaborator Author

I just pushed an update to this removing the AST aspect and (hopefully) fixing the tests so they pass in CI

haven't addressed the comments yet so it isn't reviewable yet

args = parser.parse_args(args_list)

# Handle web subcommand
if args.command == 'web':
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeahp, changed that now


@app.get('/')
@app.get('/{id}')
async def index(request: Request, version: str | None = Query(None)): # pyright: ignore[reportUnusedFunction]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I understand the need for a version arg. An older version than the default is worse, and a newer version may not work with the API data model. I think they should develop in tandem, with a pinned version on this side.

What we could do is add a frontend_url argument to the to_web method to allow the entire thing to be overridden easily?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

frontend_url remains relevant, I haven't included logic for this

@dsfaccini dsfaccini requested a review from DouweM December 1, 2025 18:12
clai/README.md Outdated

- `--agent`, `-a`: Agent to serve in `module:variable` format
- `--models`, `-m`: Comma-separated models to make available (e.g., `gpt-5,sonnet-4-5`)
- `--tools`, `-t`: Comma-separated builtin tool IDs to enable (e.g., `web_search,code_execution`)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Link to builtin tools docs please. We may also need to list all the IDs as I don't think they're documented anywhere

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is "to enable" correct? Are they all enabled by default or just available as options?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

most correctest would probably be offer or put at the disposal I guess, but enable seems good enough enable for the user

if tool_cls is None or tool_id in ('url_context', 'mcp_server'):
console.print(f'[yellow]Warning: Unknown tool "{tool_id}", skipping[/yellow]')
continue
if tool_id == 'memory':
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather heave a constant set of unsupported builtin tool IDs, and then have a generic error X is not supported in the web UI because it requires configuration or something like that

| None = None,
builtin_tools: list[AbstractBuiltinTool] | None = None,
) -> Starlette:
"""Create a Starlette app that serves a web chat UI for this agent.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had a comment somewhere about exposing more starlette arguments like to_a2a and to_ag_ui do, can we do that please?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should also support instructions, and any other options to run/iter that makes sense to override. Like model_settings etc. I believe to_ag_ui does that too

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I took that comment to mean that we should be passing the models and builtin tools as args, since they were part of a config file before, I'm not sure otherwise what other options we should make available... do you have a link to an example?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dsfaccini Essentially to_web wraps both agent.run and Starlette(), which both take many args people may want to tweak. So for example look at AbstractAgent.to_ag_ui

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added settings and deps for now, the Starlette-specific args we discussed can be set externally, let's talk if there are any agent-specific ones that are missing here

for tool in params.builtin_tools:
if not isinstance(tool, tuple(supported_types)):
raise UserError(
f'Builtin tool {type(tool).__name__} is not supported by this model. '
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If multiple tools are unsupported, we'd now get separate exceptions and only see the first. I'd rather have one exception that lists all the unsupported types. So we can do effectively if len(params.builtin_tools - self.profile.supported_builtin_tools) > 0 (with the correct types of course)

def add_api_routes(
app: Starlette,
agent: Agent,
models: list[ModelInfo] | None = None,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment on line model=extra_data.model makes me thing we should be passing the raw model instance/names into this method, not the pre-processed ModelInfo. And we should generate the ModelInfo inside this method / inside the configure endpoint.


app = Starlette()

add_api_routes(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can refactor this to return a Starlette router for just the API that can then be mounted into the main starlette app. that way it doesn't need to take app, and the API is more cleanly separate from the UI

Added link for web chat UI screenshot.
@dsfaccini
Copy link
Collaborator Author

working on removing mcp support and the remaining 4 comments

- return routes instead of passing app into craete api routes
- handle model instance resolution inside api route creator
- update docs

```bash
uvx clai
uvx clai chat
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm can we keep clai working as it did, and not require this new argument?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can, I though this would be cleaner/less confusing


## Web Chat UI

Launch a web-based chat interface for your agent:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's first have an example without an agent, to show that it will work that way as well

# used in a couple places
def get_builtin_tool_types() -> frozenset[type[AbstractBuiltinTool]]:
"""Get the set of all builtin tool types (excluding deprecated tools)."""
return frozenset(cls for kind, cls in BUILTIN_TOOL_TYPES.items() if kind not in DEPRECATED_BUILTIN_TOOL_TYPES)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of having this be a method that always returns the same thing, could this be a constant?

if extra_data.model and model_ids and extra_data.model not in model_ids:
return f'Model "{extra_data.model}" is not in the allowed models list'

# base model also valdiates this but makes sesne to have an api check, since one could be a UI bug/misbehavior
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a few typos here!

agent: Agent[AgentDepsT, OutputDataT],
models: ModelsParam = None,
builtin_tools: Sequence[AbstractBuiltinTool] | None = None,
toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need to support this yet right? As we don't do MCP for now

model_id = model_ref if isinstance(model_ref, str) else f'{model.system}:{model.model_name}'
display_name = label or model.label
model_supported_tools = model.profile.supported_builtin_tools
supported_tool_ids = [t.kind for t in (model_supported_tools & builtin_tool_types)]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this also be t.unique_id? Or at least, I think those are the values that should show up in ModelInfo. Have you tested the entire feature with multiple MCPServerTools?

# Build model ID → original reference mapping and ModelInfo list for frontend
model_id_to_ref: dict[str, Model | str] = {}
model_infos: list[ModelInfo] = []
builtin_tools = builtin_tools or []
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mentioned above that we should not send agent._builtin_tools into this method, but what we should do is check if any of the provided new builtin_tools are already in agent._builtin_tools, so that we filter them out and don't show them in the UI as a checkbox, if they're hard-coded on the agent and are always going to be included anyway.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Web chat interface for any agent

2 participants