Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
1cfa48e
add Agent.to_web() method and web chat UI module #3295
dsfaccini Nov 17, 2025
d83caa5
add "web" group
dsfaccini Nov 17, 2025
bfffd4b
try import create_chat_app
dsfaccini Nov 17, 2025
055e120
fix tests to run on CI
dsfaccini Nov 17, 2025
6bc8b16
fix example to use tool_plain
dsfaccini Nov 17, 2025
32f7e1d
add clai web - tested with uv run clai web
dsfaccini Nov 18, 2025
cf0e177
wip: remove agent discovery and fix tests
dsfaccini Nov 20, 2025
e7f44eb
rename command
dsfaccini Nov 20, 2025
c4ffde3
rename function
dsfaccini Nov 20, 2025
0595c27
- define builtin tool ids
dsfaccini Nov 21, 2025
0d24941
fix tests
dsfaccini Nov 21, 2025
e5b30c2
- update CLI commands and improve agent loading mechanism
dsfaccini Nov 21, 2025
f2dd19a
Merge branch 'main' into clai-chat
dsfaccini Nov 21, 2025
8ca7a27
import sorting
dsfaccini Nov 21, 2025
fa3bb5f
more import sorting
dsfaccini Nov 21, 2025
e45c93f
covergae?
dsfaccini Nov 22, 2025
f90b570
Merge upstream/main into clai-chat
dsfaccini Nov 22, 2025
da8032c
Merge upstream/main into clai-chat
dsfaccini Nov 26, 2025
0ab07b6
- remove agent_options - add supported_builtin_tools - swap fastapi f…
dsfaccini Nov 26, 2025
191897f
refactor from --web to web, adjust flags, add tests, update docs
dsfaccini Nov 26, 2025
1faae3f
remove clai import on test
dsfaccini Nov 26, 2025
8423e89
coverage
dsfaccini Nov 26, 2025
a32c15a
- consolidate web docs in own doc
dsfaccini Nov 27, 2025
91f9533
remove memory cache and swap prints for consoles
dsfaccini Nov 27, 2025
441d6a0
use snapshots where it makes sense
dsfaccini Nov 27, 2025
558985f
Merge branch 'main' into clai-chat
dsfaccini Nov 27, 2025
85f63bc
make _web a private module
dsfaccini Nov 28, 2025
cd91e81
move supported_builtin_tools to abstract tool set type
dsfaccini Nov 28, 2025
a132f26
warning about memory tool
dsfaccini Nov 29, 2025
943c7a8
move name formatting to model method
dsfaccini Nov 29, 2025
831bdf3
move to toolsets
dsfaccini Nov 29, 2025
125c059
update docstring
dsfaccini Nov 29, 2025
025f1b5
take agent's own model into account
dsfaccini Nov 29, 2025
0701b22
bit of docs
dsfaccini Nov 29, 2025
40b5c78
move builtin tool support to profile and move check to base model class
dsfaccini Nov 29, 2025
0673277
fix test profile classes to avoid ci api key error
dsfaccini Nov 29, 2025
974bac2
Merge branch 'main' into clai-chat
dsfaccini Nov 29, 2025
4b58307
coverage
dsfaccini Nov 29, 2025
74f09e9
add anthropic prefix
dsfaccini Dec 1, 2025
897e4e4
fix naming
dsfaccini Dec 1, 2025
10d32fe
remove get agent utility and make supported builtin tools a classmethod
dsfaccini Dec 1, 2025
1c93cf0
remove test
dsfaccini Dec 1, 2025
b60f2ff
Add screenshot link for web chat UI
dsfaccini Dec 3, 2025
ee21cfb
- remove builtin_tools suport check for specific models, base model t…
dsfaccini Dec 3, 2025
cf1da90
support double instructions
dsfaccini Dec 3, 2025
372db8b
remove program check, tie tool arg options to constant and set up aut…
dsfaccini Dec 4, 2025
32cc5c5
merge
dsfaccini Dec 4, 2025
48094d2
add support for model instances, deps and settings
dsfaccini Dec 4, 2025
cd1e683
fix tests after cli adjustments
dsfaccini Dec 4, 2025
c1ba780
separate chat command
dsfaccini Dec 4, 2025
6984dfd
Merge branch 'main' into clai-chat
dsfaccini Dec 4, 2025
cf54985
Merge branch 'main' into clai-chat
dsfaccini Dec 4, 2025
106c8b9
- remove mcp support for now
dsfaccini Dec 4, 2025
f491e57
Merge branch 'main' into clai-chat
dsfaccini Dec 4, 2025
16e36dc
narrow types
dsfaccini Dec 4, 2025
48bd838
Merge branch 'main' into clai-chat
dsfaccini Dec 5, 2025
b45156c
Update clai/README.md
dsfaccini Dec 9, 2025
3733a56
Merge branch 'main' into clai-chat
dsfaccini Dec 9, 2025
b57fb38
Update clai/README.md
dsfaccini Dec 9, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions clai/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,46 @@ Either way, running `clai` will start an interactive session where you can chat
- `/multiline`: Toggle multiline input mode (use Ctrl+D to submit)
- `/cp`: Copy the last response to clipboard

## 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


```bash
clai web module:agent_variable
```

For example, if you have an agent defined in `my_agent.py`:

```python
from pydantic_ai import Agent

my_agent = Agent('openai:gpt-5', system_prompt='You are a helpful assistant.')
```

Launch the web UI with:

```bash
clai web my_agent:my_agent
```

This will start a web server (default: http://127.0.0.1:8000) with a chat interface for your agent.

### Web Command Options

- `--host`: Host to bind the server to (default: 127.0.0.1)
- `--port`: Port to bind the server to (default: 8000)
- `--config`: Path to custom `agent_options.py` config file
- `--no-auto-config`: Disable auto-discovery of `agent_options.py` in current directory

You can also launch the web UI directly from an `Agent` instance using `Agent.to_web()`:

```python
from pydantic_ai import Agent

agent = Agent('openai:gpt-5')
app = agent.to_web() # Returns a FastAPI application
```

## Help

```
Expand Down
5 changes: 5 additions & 0 deletions clai/clai/web/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Chat UI module for clai."""

from .cli import run_chat_command

__all__ = ['run_chat_command']
138 changes: 138 additions & 0 deletions clai/clai/web/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
"""CLI command for launching a web chat UI for discovered agents."""

from __future__ import annotations

import importlib
import importlib.util
import sys
from pathlib import Path

from pydantic_ai import Agent
from pydantic_ai.builtin_tools import AbstractBuiltinTool
from pydantic_ai.ui.web import AIModel, BuiltinTool, create_chat_app


def load_agent_options(
config_path: Path,
) -> tuple[list[AIModel] | None, dict[str, AbstractBuiltinTool] | None, list[BuiltinTool] | None]:
"""Load agent options from a config file.

Args:
config_path: Path to the config file (e.g., agent_options.py)

Returns:
Tuple of (models, builtin_tools, builtin_tool_defs) or (None, None, None) if not found
"""
if not config_path.exists():
return None, None, None

try:
spec = importlib.util.spec_from_file_location('agent_options_config', config_path)
if spec is None or spec.loader is None:
print(f'Warning: Could not load config from {config_path}')
return None, None, None

module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)

models = getattr(module, 'AI_MODELS', None)
builtin_tools = getattr(module, 'BUILTIN_TOOLS', None)
builtin_tool_defs = getattr(module, 'BUILTIN_TOOL_DEFS', None)

return models, builtin_tools, builtin_tool_defs

except Exception as e:
print(f'Warning: Error loading config from {config_path}: {e}')
return None, None, None


def load_agent(agent_path: str) -> Agent | None:
"""Load an agent from module path in uvicorn style.

Args:
agent_path: Path in format 'module:variable', e.g. 'test_agent:my_agent'

Returns:
Agent instance or None if loading fails
"""
sys.path.insert(0, str(Path.cwd()))

try:
module_path, variable_name = agent_path.split(':')
except ValueError:
print('Error: Agent must be specified in "module:variable" format')
return None

try:
module = importlib.import_module(module_path)
agent = getattr(module, variable_name, None)

if agent is None:
print(f'Error: {variable_name} not found in module {module_path}')
return None

if not isinstance(agent, Agent):
print(f'Error: {variable_name} is not an Agent instance')
return None

return agent # pyright: ignore[reportUnknownVariableType]

except ImportError as e:
print(f'Error: Could not import module {module_path}: {e}')
return None
except Exception as e:
print(f'Error loading agent: {e}')
return None


def run_chat_command(
agent_path: str,
host: str = '127.0.0.1',
port: int = 8000,
config_path: Path | None = None,
auto_config: bool = True,
) -> int:
"""Run the chat command to serve an agent via web UI.

Args:
agent_path: Agent path in 'module:variable' format, e.g. 'test_agent:my_agent'
host: Host to bind the server to
port: Port to bind the server to
config_path: Path to agent_options.py config file
auto_config: Auto-discover agent_options.py in current directory
"""
agent = load_agent(agent_path)
if agent is None:
return 1

models, builtin_tools, builtin_tool_defs = None, None, None
if config_path:
print(f'Loading config from {config_path}...')
models, builtin_tools, builtin_tool_defs = load_agent_options(config_path)
elif auto_config:
default_config = Path.cwd() / 'agent_options.py'
if default_config.exists():
print(f'Found config file: {default_config}')
models, builtin_tools, builtin_tool_defs = load_agent_options(default_config)

app = create_chat_app(agent, models=models, builtin_tools=builtin_tools, builtin_tool_defs=builtin_tool_defs)

print(f'\nStarting chat UI for {agent_path}...')
print(f'Open your browser at: http://{host}:{port}')
print('Press Ctrl+C to stop the server\n')

try:
import uvicorn

uvicorn.run(app, host=host, port=port)
return 0
except KeyboardInterrupt:
print('\nServer stopped.')
return 0
except ImportError:
print('Error: uvicorn is required to run the chat UI')
print('Install it with: pip install uvicorn')
return 1
except Exception as e:
print(f'Error starting server: {e}')
return 1
46 changes: 46 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,52 @@ Either way, running `clai` will start an interactive session where you can chat
- `/multiline`: Toggle multiline input mode (use Ctrl+D to submit)
- `/cp`: Copy the last response to clipboard

### Web Chat UI
Copy link
Collaborator

Choose a reason for hiding this comment

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

There's a lot of duplication here with clai/README.md. Maybe that readme should just link to this page?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yeah I wasn't sure about it as well, I thought there maybe be people who look more at the repo than the docs, I know I sometimes do, so I left it in both places, but I can just link to the docs from the readme.


Launch a web-based chat interface for your agent:

```bash
clai web module:agent_variable
```

For example, if you have an agent defined in `my_agent.py`:

```python
from pydantic_ai import Agent

my_agent = Agent('openai:gpt-5', system_prompt='You are a helpful assistant.')
```

Launch the web UI with:

```bash
clai web my_agent:my_agent
```

This will start a web server (default: http://127.0.0.1:8000) with a chat interface for your agent.

#### Web Command Options

- `--host`: Host to bind the server to (default: 127.0.0.1)
- `--port`: Port to bind the server to (default: 8000)
- `--config`: Path to custom `agent_options.py` config file
- `--no-auto-config`: Disable auto-discovery of `agent_options.py` in current directory

You can also launch the web UI directly from an `Agent` instance using [`Agent.to_web()`][pydantic_ai.Agent.to_web]:

```python
from pydantic_ai import Agent

agent = Agent('openai:gpt-5')
app = agent.to_web() # Returns a FastAPI application
```

The returned FastAPI app can be run with your preferred ASGI server (uvicorn, hypercorn, etc.):

```bash
uvicorn my_module:app --host 0.0.0.0 --port 8080
```

### Help

To get help on the CLI, use the `--help` flag:
Expand Down
105 changes: 74 additions & 31 deletions pydantic_ai_slim/pydantic_ai/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,15 @@ def cli( # noqa: C901
args_list: Sequence[str] | None = None, *, prog_name: str = 'pai', default_model: str = 'openai:gpt-5'
) -> int:
"""Run the CLI and return the exit code for the process."""
# Pre-check for web subcommand to avoid argparse conflict with prompt positional
check_args = args_list if args_list is not None else sys.argv[1:]
first_positional = next((arg for arg in check_args if not arg.startswith('-')), None)
is_web_subcommand = prog_name == 'clai' and first_positional == 'web'

# we don't want to autocomplete or list models that don't include the provider,
# e.g. we want to show `openai:gpt-4o` but not `gpt-4o`
qualified_model_names = [n for n in get_literal_values(KnownModelName.__value__) if ':' in n]

parser = argparse.ArgumentParser(
prog=prog_name,
description=f"""\
Expand All @@ -119,41 +128,75 @@ def cli( # noqa: C901
""",
formatter_class=argparse.RawTextHelpFormatter,
)
parser.add_argument('prompt', nargs='?', help='AI Prompt, if omitted fall into interactive mode')
arg = parser.add_argument(
'-m',
'--model',
nargs='?',
help=f'Model to use, in format "<provider>:<model>" e.g. "openai:gpt-5" or "anthropic:claude-sonnet-4-5". Defaults to "{default_model}".',
)
# we don't want to autocomplete or list models that don't include the provider,
# e.g. we want to show `openai:gpt-4o` but not `gpt-4o`
qualified_model_names = [n for n in get_literal_values(KnownModelName.__value__) if ':' in n]
arg.completer = argcomplete.ChoicesCompleter(qualified_model_names) # type: ignore[reportPrivateUsage]
parser.add_argument(
'-a',
'--agent',
help='Custom Agent to use, in format "module:variable", e.g. "mymodule.submodule:my_agent"',
)
parser.add_argument(
'-l',
'--list-models',
action='store_true',
help='List all available models and exit',
)
parser.add_argument(
'-t',
'--code-theme',
nargs='?',
help='Which colors to use for code, can be "dark", "light" or any theme from pygments.org/styles/. Defaults to "dark" which works well on dark terminals.',
default='dark',
)
parser.add_argument('--no-stream', action='store_true', help='Disable streaming from the model')
parser.add_argument('--version', action='store_true', help='Show version and exit')

if is_web_subcommand:
# Web subcommand mode - add subparsers only
subparsers = parser.add_subparsers(dest='command', help='Available commands')
web_parser = subparsers.add_parser('web', help='Launch web chat UI for an agent')
web_parser.add_argument(
'agent',
help='Agent to load in "module:variable" format, e.g. "test_agent:my_agent"',
)
web_parser.add_argument('--host', default='127.0.0.1', help='Host to bind the server to (default: 127.0.0.1)')
web_parser.add_argument('--port', type=int, default=8000, help='Port to bind the server to (default: 8000)')
web_parser.add_argument(
'--config', type=Path, help='Path to agent_options.py config file (overrides auto-discovery)'
)
web_parser.add_argument(
'--no-auto-config',
action='store_true',
help='Disable auto-discovery of agent_options.py in current directory',
)
else:
# Prompt mode - add prompt positional and flags
parser.add_argument('prompt', nargs='?', help='AI Prompt, if omitted fall into interactive mode')

arg = parser.add_argument(
'-m',
'--model',
nargs='?',
help=f'Model to use, in format "<provider>:<model>" e.g. "openai:gpt-5" or "anthropic:claude-sonnet-4-5". Defaults to "{default_model}".',
)
arg.completer = argcomplete.ChoicesCompleter(qualified_model_names) # type: ignore[reportPrivateUsage]
parser.add_argument(
'-a',
'--agent',
help='Custom Agent to use, in format "module:variable", e.g. "mymodule.submodule:my_agent"',
)
parser.add_argument(
'-l',
'--list-models',
action='store_true',
help='List all available models and exit',
)
parser.add_argument(
'-t',
'--code-theme',
nargs='?',
help='Which colors to use for code, can be "dark", "light" or any theme from pygments.org/styles/. Defaults to "dark" which works well on dark terminals.',
default='dark',
)
parser.add_argument('--no-stream', action='store_true', help='Disable streaming from the model')
parser.add_argument('--version', action='store_true', help='Show version and exit')

argcomplete.autocomplete(parser)
args = parser.parse_args(args_list)

# Handle web subcommand
if getattr(args, 'command', None) == 'web':
try:
from clai.web.cli import run_chat_command
except ImportError:
print('Error: clai web command is only available when clai is installed.')
return 1
return run_chat_command(
agent_path=args.agent,
host=args.host,
port=args.port,
config_path=args.config,
auto_config=not args.no_auto_config,
)

console = Console()
name_version = f'[green]{prog_name} - Pydantic AI CLI v{__version__}[/green]'
if args.version:
Expand Down
Loading
Loading