feat: add end_call tool (#118)

* feat: add end_call tool

* chore: remove run_llm=True from properties
This commit is contained in:
Abhishek 2026-01-14 16:40:40 +05:30 committed by GitHub
parent e7712474c1
commit a172db8022
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1274 additions and 716 deletions

View file

@ -0,0 +1,50 @@
"""add end_call tool category
Revision ID: 493ca2bb001f
Revises: b79f19f68157
Create Date: 2026-01-14 15:04:48.899778
"""
from typing import Sequence, Union
from alembic import op
from alembic_postgresql_enum import TableReference
# revision identifiers, used by Alembic.
revision: str = "493ca2bb001f"
down_revision: Union[str, None] = "b79f19f68157"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.sync_enum_values(
enum_schema="public",
enum_name="tool_category",
new_values=["http_api", "end_call", "native", "integration"],
affected_columns=[
TableReference(
table_schema="public", table_name="tools", column_name="category"
)
],
enum_values_to_rename=[],
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.sync_enum_values(
enum_schema="public",
enum_name="tool_category",
new_values=["http_api", "native", "integration"],
affected_columns=[
TableReference(
table_schema="public", table_name="tools", column_name="category"
)
],
enum_values_to_rename=[],
)
# ### end Alembic commands ###

View file

@ -0,0 +1,34 @@
"""remove unique tool name constraint
Revision ID: dcb0a27d98c6
Revises: 493ca2bb001f
Create Date: 2026-01-14 16:07:01.940879
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "dcb0a27d98c6"
down_revision: Union[str, None] = "493ca2bb001f"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(op.f("unique_org_tool_name"), "tools", type_="unique")
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_unique_constraint(
op.f("unique_org_tool_name"),
"tools",
["organization_id", "name"],
postgresql_nulls_not_distinct=False,
)
# ### end Alembic commands ###

View file

@ -889,5 +889,4 @@ class ToolModel(Base):
Index("ix_tools_uuid", "tool_uuid"),
Index("ix_tools_status", "status"),
Index("ix_tools_category", "category"),
UniqueConstraint("organization_id", "name", name="unique_org_tool_name"),
)

View file

@ -86,7 +86,12 @@ class ToolClient(BaseDBClient):
)
if status:
query = query.where(ToolModel.status == status)
# Support comma-separated status values (e.g., "active,archived")
status_list = [s.strip() for s in status.split(",")]
if len(status_list) > 1:
query = query.where(ToolModel.status.in_(status_list))
else:
query = query.where(ToolModel.status == status)
else:
# By default, exclude archived tools
query = query.where(ToolModel.status != ToolStatus.ARCHIVED.value)
@ -233,6 +238,44 @@ class ToolClient(BaseDBClient):
return True
return False
async def unarchive_tool(
self, tool_uuid: str, organization_id: int
) -> Optional[ToolModel]:
"""Restore an archived tool by setting its status to active.
Args:
tool_uuid: The unique tool UUID
organization_id: ID of the organization (for authorization)
Returns:
The unarchived ToolModel if found, None otherwise
"""
async with self.async_session() as session:
result = await session.execute(
update(ToolModel)
.where(
ToolModel.tool_uuid == tool_uuid,
ToolModel.organization_id == organization_id,
ToolModel.status == ToolStatus.ARCHIVED.value,
)
.values(
status=ToolStatus.ACTIVE.value,
updated_at=datetime.now(UTC),
)
)
await session.commit()
if result.rowcount > 0:
logger.info(
f"Unarchived tool {tool_uuid} for organization {organization_id}"
)
# Fetch and return the updated tool
result = await session.execute(
select(ToolModel).where(ToolModel.tool_uuid == tool_uuid)
)
return result.scalar_one_or_none()
return None
async def validate_tool_uuid(self, tool_uuid: str, organization_id: int) -> bool:
"""Check if a tool UUID exists and belongs to the organization.

View file

@ -121,9 +121,8 @@ class ToolCategory(Enum):
"""Tool category types"""
HTTP_API = "http_api" # Custom HTTP API calls (implemented)
NATIVE = (
"native" # Built-in integrations (future: call_transfer, dtmf_input, end_call)
)
END_CALL = "end_call" # End call tool
NATIVE = "native" # Built-in integrations (future: call_transfer, dtmf_input)
INTEGRATION = "integration" # Third-party integrations (future: Google Calendar, Salesforce, etc.)

View file

@ -1,7 +1,7 @@
"""API routes for managing tools."""
from datetime import datetime
from typing import Any, Dict, List, Optional
from typing import Annotated, Any, Dict, List, Literal, Optional, Union
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
@ -45,14 +45,38 @@ class HttpApiConfig(BaseModel):
)
class ToolDefinition(BaseModel):
"""Tool definition schema."""
class EndCallConfig(BaseModel):
"""Configuration for End Call tools."""
schema_version: int = Field(
default=1, description="Schema version for compatibility"
messageType: Literal["none", "custom"] = Field(
default="none", description="Type of goodbye message"
)
type: str = Field(description="Tool type (http_api)")
config: HttpApiConfig = Field(description="Tool configuration")
customMessage: Optional[str] = Field(
default=None, description="Custom message to play before ending the call"
)
class HttpApiToolDefinition(BaseModel):
"""Tool definition for HTTP API tools."""
schema_version: int = Field(default=1, description="Schema version")
type: Literal["http_api"] = Field(description="Tool type")
config: HttpApiConfig = Field(description="HTTP API configuration")
class EndCallToolDefinition(BaseModel):
"""Tool definition for End Call tools."""
schema_version: int = Field(default=1, description="Schema version")
type: Literal["end_call"] = Field(description="Tool type")
config: EndCallConfig = Field(description="End Call configuration")
# Union type for tool definitions - Pydantic will discriminate based on 'type' field
ToolDefinition = Annotated[
Union[HttpApiToolDefinition, EndCallToolDefinition],
Field(discriminator="type"),
]
class CreateToolRequest(BaseModel):
@ -140,13 +164,15 @@ def validate_category(category: str) -> None:
def validate_status(status: str) -> None:
"""Validate that the status is valid."""
"""Validate that the status is valid. Supports comma-separated values."""
valid_statuses = [s.value for s in ToolStatus]
if status not in valid_statuses:
raise HTTPException(
status_code=400,
detail=f"Invalid status '{status}'. Must be one of: {', '.join(valid_statuses)}",
)
status_list = [s.strip() for s in status.split(",")]
for s in status_list:
if s not in valid_statuses:
raise HTTPException(
status_code=400,
detail=f"Invalid status '{s}'. Must be one of: {', '.join(valid_statuses)}",
)
@router.get("/")
@ -205,27 +231,18 @@ async def create_tool(
validate_category(request.category)
try:
tool = await db_client.create_tool(
organization_id=user.selected_organization_id,
user_id=user.id,
name=request.name,
definition=request.definition.model_dump(),
category=request.category,
description=request.description,
icon=request.icon,
icon_color=request.icon_color,
)
tool = await db_client.create_tool(
organization_id=user.selected_organization_id,
user_id=user.id,
name=request.name,
definition=request.definition.model_dump(),
category=request.category,
description=request.description,
icon=request.icon,
icon_color=request.icon_color,
)
return build_tool_response(tool)
except Exception as e:
if "unique_org_tool_name" in str(e):
raise HTTPException(
status_code=409,
detail=f"A tool with the name '{request.name}' already exists",
)
raise HTTPException(status_code=500, detail=str(e))
return build_tool_response(tool)
@router.get("/{tool_uuid}")
@ -281,32 +298,21 @@ async def update_tool(
if request.status:
validate_status(request.status)
try:
tool = await db_client.update_tool(
tool_uuid=tool_uuid,
organization_id=user.selected_organization_id,
name=request.name,
description=request.description,
definition=request.definition.model_dump() if request.definition else None,
icon=request.icon,
icon_color=request.icon_color,
status=request.status,
)
tool = await db_client.update_tool(
tool_uuid=tool_uuid,
organization_id=user.selected_organization_id,
name=request.name,
description=request.description,
definition=request.definition.model_dump() if request.definition else None,
icon=request.icon,
icon_color=request.icon_color,
status=request.status,
)
if not tool:
raise HTTPException(status_code=404, detail="Tool not found")
if not tool:
raise HTTPException(status_code=404, detail="Tool not found")
return build_tool_response(tool, include_created_by=True)
except HTTPException:
raise
except Exception as e:
if "unique_org_tool_name" in str(e):
raise HTTPException(
status_code=409,
detail=f"A tool with the name '{request.name}' already exists",
)
raise HTTPException(status_code=500, detail=str(e))
return build_tool_response(tool, include_created_by=True)
@router.delete("/{tool_uuid}")
@ -334,3 +340,30 @@ async def delete_tool(
raise HTTPException(status_code=404, detail="Tool not found")
return {"status": "archived", "tool_uuid": tool_uuid}
@router.post("/{tool_uuid}/unarchive")
async def unarchive_tool(
tool_uuid: str,
user: UserModel = Depends(get_user),
) -> ToolResponse:
"""
Unarchive a tool (restore from archived state).
Args:
tool_uuid: The UUID of the tool to unarchive
Returns:
The unarchived tool
"""
if not user.selected_organization_id:
raise HTTPException(
status_code=400, detail="No organization selected for the user"
)
tool = await db_client.unarchive_tool(tool_uuid, user.selected_organization_id)
if not tool:
raise HTTPException(status_code=404, detail="Tool not found")
return build_tool_response(tool)

View file

@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, Any, Optional
from loguru import logger
from api.db import db_client
from api.enums import ToolCategory
from api.services.workflow.disposition_mapper import (
get_organization_id_from_workflow_run,
)
@ -20,13 +21,17 @@ from api.services.workflow.tools.custom_tool import (
tool_to_function_schema,
)
from pipecat.adapters.schemas.function_schema import FunctionSchema
from pipecat.frames.frames import FunctionCallResultProperties
from pipecat.frames.frames import FunctionCallResultProperties, TTSSpeakFrame
from pipecat.services.llm_service import FunctionCallParams
if TYPE_CHECKING:
from api.services.workflow.pipecat_engine import PipecatEngine
# End task reason for end call tool
END_CALL_TOOL_REASON = "end_call_tool"
class CustomToolManager:
"""Manager for custom tool registration and execution.
@ -34,14 +39,12 @@ class CustomToolManager:
1. Fetching tools from the database based on tool UUIDs
2. Converting tools to LLM function schemas
3. Registering tool execution handlers with the LLM
4. Executing HTTP API tools when invoked by the LLM
4. Executing tools when invoked by the LLM
"""
def __init__(self, engine: "PipecatEngine") -> None:
self._engine = engine
self._organization_id: Optional[int] = None
# Cache: maps function_name -> (tool, schema)
self._tools_cache: dict[str, tuple[Any, dict]] = {}
async def get_organization_id(self) -> Optional[int]:
"""Get and cache the organization ID from workflow run."""
@ -73,9 +76,6 @@ class CustomToolManager:
raw_schema = tool_to_function_schema(tool)
function_name = raw_schema["function"]["name"]
# Cache the tool for later execution
self._tools_cache[function_name] = (tool, raw_schema)
# Convert to FunctionSchema object for compatibility with update_llm_context
func_schema = get_function_schema(
function_name,
@ -117,9 +117,6 @@ class CustomToolManager:
schema = tool_to_function_schema(tool)
function_name = schema["function"]["name"]
# Cache the tool for potential later use
self._tools_cache[function_name] = (tool, schema)
# Create and register the handler
handler = self._create_handler(tool, function_name)
self._engine.llm.register_function(function_name, handler)
@ -133,7 +130,7 @@ class CustomToolManager:
logger.error(f"Failed to register custom tool handlers: {e}")
def _create_handler(self, tool: Any, function_name: str):
"""Create a handler function for a custom tool.
"""Create a handler function for a tool based on its category.
Args:
tool: The ToolModel instance
@ -142,17 +139,29 @@ class CustomToolManager:
Returns:
Async handler function for the tool
"""
# Run LLM after tool execution to continue conversation
properties = FunctionCallResultProperties(run_llm=True)
if tool.category == ToolCategory.END_CALL.value:
return self._create_end_call_handler(tool, function_name)
async def custom_tool_handler(
return self._create_http_tool_handler(tool, function_name)
def _create_http_tool_handler(self, tool: Any, function_name: str):
"""Create a handler function for an HTTP API tool.
Args:
tool: The ToolModel instance
function_name: The function name used by the LLM
Returns:
Async handler function for the HTTP API tool
"""
async def http_tool_handler(
function_call_params: FunctionCallParams,
) -> None:
logger.info(f"LLM Function Call EXECUTED: {function_name}")
logger.info(f"HTTP Tool EXECUTED: {function_name}")
logger.info(f"Arguments: {function_call_params.arguments}")
try:
# Execute the HTTP API tool
result = await execute_http_tool(
tool=tool,
arguments=function_call_params.arguments,
@ -160,30 +169,66 @@ class CustomToolManager:
organization_id=self._organization_id,
)
await function_call_params.result_callback(
result, properties=properties
)
await function_call_params.result_callback(result)
except Exception as e:
logger.error(f"Custom tool '{function_name}' execution failed: {e}")
logger.error(f"HTTP tool '{function_name}' execution failed: {e}")
await function_call_params.result_callback(
{"status": "error", "error": str(e)},
properties=properties,
{"status": "error", "error": str(e)}
)
return custom_tool_handler
return http_tool_handler
def get_cached_tool(self, function_name: str) -> Optional[tuple[Any, dict]]:
"""Get a cached tool by its function name.
def _create_end_call_handler(self, tool: Any, function_name: str):
"""Create a handler function for an end call tool.
Args:
tool: The ToolModel instance
function_name: The function name used by the LLM
Returns:
Tuple of (tool, schema) if found, None otherwise
Async handler function for the end call tool
"""
return self._tools_cache.get(function_name)
# Don't run LLM after end call - we're terminating
properties = FunctionCallResultProperties(run_llm=False)
def clear_cache(self) -> None:
"""Clear the tools cache."""
self._tools_cache.clear()
async def end_call_handler(
function_call_params: FunctionCallParams,
) -> None:
logger.info(f"End Call Tool EXECUTED: {function_name}")
try:
# Get the end call configuration
config = tool.definition.get("config", {})
message_type = config.get("messageType", "none")
custom_message = config.get("customMessage", "")
# Send result callback first
await function_call_params.result_callback(
{"status": "success", "action": "ending_call"},
properties=properties,
)
if message_type == "custom" and custom_message:
# Queue the custom message to be spoken
logger.info(f"Playing custom goodbye message: {custom_message}")
await self._engine.task.queue_frame(TTSSpeakFrame(custom_message))
# End the call after the message (not immediately)
await self._engine.send_end_task_frame(
END_CALL_TOOL_REASON, abort_immediately=False
)
else:
# No message - end call immediately
logger.info("Ending call immediately (no goodbye message)")
await self._engine.send_end_task_frame(
END_CALL_TOOL_REASON, abort_immediately=True
)
except Exception as e:
logger.error(f"End call tool '{function_name}' execution failed: {e}")
# Still try to end the call even if there's an error
await self._engine.send_end_task_frame(
END_CALL_TOOL_REASON, abort_immediately=True
)
return end_call_handler

View file

@ -113,7 +113,6 @@ class WorkflowGraph:
# )
errors.extend(self._assert_start_node())
errors.extend(self._assert_end_node())
errors.extend(self._assert_connection_counts())
errors.extend(self._assert_global_node())
errors.extend(self._assert_node_configs())
@ -174,30 +173,6 @@ class WorkflowGraph:
)
return errors
def _assert_end_node(self):
errors: list[WorkflowError] = []
end_node = [n for n in self.nodes.values() if n.data.is_end]
if not end_node:
errors.append(
WorkflowError(
kind=ItemKind.workflow,
id=None,
field=None,
message="Workflow must have exactly one end node",
)
)
elif len(end_node) > 1:
errors.append(
WorkflowError(
kind=ItemKind.workflow,
id=None,
field=None,
message="Workflow must have exactly one end node",
)
)
return errors
def _assert_connection_counts(self):
errors: list[WorkflowError] = []
@ -235,13 +210,13 @@ class WorkflowGraph:
)
)
case NodeType.agentNode:
if in_d < 1 or out_d < 1:
if in_d < 1:
errors.append(
WorkflowError(
kind=ItemKind.node,
id=n.id,
field=None,
message=f"Worker must have at least 1 incoming and 1 outgoing edge",
message=f"Worker must have at least 1 incoming edge",
)
)

View file

@ -43,6 +43,7 @@ class MockToolModel:
tool_uuid: str
name: str
description: str
category: str
definition: Dict[str, Any]
@ -55,6 +56,7 @@ class TestToolToFunctionSchema:
tool_uuid="test-uuid-1",
name="Get Weather",
description="Get current weather for a location",
category="http_api",
definition={
"schema_version": 1,
"type": "http_api",
@ -97,6 +99,7 @@ class TestToolToFunctionSchema:
tool_uuid="test-uuid-2",
name="Book Appointment",
description="Book an appointment with the service",
category="http_api",
definition={
"schema_version": 1,
"type": "http_api",
@ -145,6 +148,7 @@ class TestToolToFunctionSchema:
tool_uuid="test-uuid-3",
name="Get User's Account Info!!!",
description="Get account information",
category="http_api",
definition={
"schema_version": 1,
"type": "http_api",
@ -167,6 +171,7 @@ class TestToolToFunctionSchema:
tool_uuid="test-uuid-4",
name="Ping Server",
description="Check if server is alive",
category="http_api",
definition={
"schema_version": 1,
"type": "http_api",
@ -188,6 +193,7 @@ class TestToolToFunctionSchema:
tool_uuid="test-uuid-5",
name="My Tool",
description=None,
category="http_api",
definition={
"schema_version": 1,
"type": "http_api",
@ -213,6 +219,7 @@ class TestExecuteHttpTool:
tool_uuid="test-uuid",
name="Create User",
description="Create a new user",
category="http_api",
definition={
"schema_version": 1,
"type": "http_api",
@ -257,6 +264,7 @@ class TestExecuteHttpTool:
tool_uuid="test-uuid",
name="Search Users",
description="Search for users",
category="http_api",
definition={
"schema_version": 1,
"type": "http_api",
@ -297,6 +305,7 @@ class TestExecuteHttpTool:
tool_uuid="test-uuid",
name="Delete User",
description="Delete a user",
category="http_api",
definition={
"schema_version": 1,
"type": "http_api",
@ -336,6 +345,7 @@ class TestExecuteHttpTool:
tool_uuid="test-uuid",
name="Slow API",
description="A slow API call",
category="http_api",
definition={
"schema_version": 1,
"type": "http_api",
@ -368,6 +378,7 @@ class TestExecuteHttpTool:
tool_uuid="test-uuid",
name="API with Headers",
description="API that requires headers",
category="http_api",
definition={
"schema_version": 1,
"type": "http_api",
@ -406,6 +417,7 @@ class TestExecuteHttpTool:
tool_uuid="test-uuid",
name="Authenticated API",
description="API that requires authentication",
category="http_api",
definition={
"schema_version": 1,
"type": "http_api",
@ -457,6 +469,7 @@ class TestExecuteHttpTool:
tool_uuid="test-uuid",
name="API with Credential",
description="API with credential configured",
category="http_api",
definition={
"schema_version": 1,
"type": "http_api",
@ -724,6 +737,7 @@ class TestCustomToolManagerUnit:
tool_uuid="uuid-1",
name="Test Tool",
description="A test tool",
category="http_api",
definition={
"schema_version": 1,
"type": "http_api",
@ -791,6 +805,7 @@ class TestCustomToolManagerUnit:
tool_uuid="uuid-1",
name="API Call",
description="Make an API call",
category="http_api",
definition={
"schema_version": 1,
"type": "http_api",
@ -846,53 +861,6 @@ class TestCustomToolManagerUnit:
# Verify result was returned
assert result_received["status"] == "success"
@pytest.mark.asyncio
async def test_tools_cache_prevents_duplicate_fetches(self):
"""Test that tools are cached after first fetch."""
from api.services.workflow.pipecat_engine_custom_tools import CustomToolManager
mock_engine = Mock()
mock_engine._workflow_run_id = 1
mock_engine._call_context_vars = {}
mock_engine.llm = Mock()
mock_engine.llm.register_function = Mock()
manager = CustomToolManager(mock_engine)
mock_tool = MockToolModel(
tool_uuid="uuid-1",
name="Cached Tool",
description="A tool that should be cached",
definition={
"schema_version": 1,
"type": "http_api",
"config": {"method": "GET", "url": "https://api.example.com"},
},
)
with patch(
"api.services.workflow.pipecat_engine_custom_tools.get_organization_id_from_workflow_run"
) as mock_get_org:
mock_get_org.return_value = 1
with patch(
"api.services.workflow.pipecat_engine_custom_tools.db_client"
) as mock_db:
mock_db.get_tools_by_uuids = AsyncMock(return_value=[mock_tool])
# First call should fetch from DB
await manager.get_tool_schemas(["uuid-1"])
# Verify tool is now in cache
cached = manager.get_cached_tool("cached_tool")
assert cached is not None
assert cached[0].tool_uuid == "uuid-1"
# Clear cache and verify it's empty
manager.clear_cache()
cached = manager.get_cached_tool("cached_tool")
assert cached is None
class TestUpdateLLMContext:
"""Tests for update_llm_context function."""

View file

@ -206,31 +206,6 @@ class TestCustomToolManagerContextIntegration:
assert "get_current_time" in tool_names
assert "get_weather" in tool_names
@pytest.mark.asyncio
async def test_tools_cached_after_first_fetch(self, mock_engine, sample_tools):
"""Test that CustomToolManager caches tools after first fetch."""
manager = CustomToolManager(mock_engine)
with patch(
"api.services.workflow.pipecat_engine_custom_tools.get_organization_id_from_workflow_run"
) as mock_get_org:
mock_get_org.return_value = 1
with patch(
"api.services.workflow.pipecat_engine_custom_tools.db_client"
) as mock_db:
mock_db.get_tools_by_uuids = AsyncMock(return_value=[sample_tools[0]])
# First fetch
await manager.get_tool_schemas(["weather-uuid-123"])
# Verify tool is cached (cache stores raw schema dict, not FunctionSchema)
cached = manager.get_cached_tool("get_weather")
assert cached is not None
tool, raw_schema = cached
assert tool.tool_uuid == "weather-uuid-123"
assert raw_schema["function"]["name"] == "get_weather"
@pytest.mark.asyncio
async def test_context_preserves_function_call_history(
self, mock_engine, sample_tools

View file

@ -13,6 +13,7 @@ import pytest
from api.services.workflow.pipecat_engine import PipecatEngine
from api.services.workflow.workflow import WorkflowGraph
from api.tests.conftest import END_CALL_SYSTEM_PROMPT, MockTransportProcessor
from pipecat.frames.frames import LLMContextFrame
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.runner import PipelineRunner
from pipecat.pipeline.task import PipelineParams, PipelineTask
@ -123,6 +124,7 @@ async def run_pipeline_with_tool_calls(
# Small delay to let runner start
await asyncio.sleep(0.01)
await engine.initialize()
await engine.llm.queue_frame(LLMContextFrame(engine.context))
# Run both concurrently
await asyncio.gather(run_pipeline(), initialize_engine())

View file

@ -15,6 +15,7 @@ import pytest
from api.services.workflow.pipecat_engine import PipecatEngine
from api.services.workflow.workflow import WorkflowGraph
from api.tests.conftest import MockTransportProcessor
from pipecat.frames.frames import LLMContextFrame
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.runner import PipelineRunner
from pipecat.pipeline.task import PipelineParams, PipelineTask
@ -128,6 +129,7 @@ async def run_pipeline_with_user_idle(
# Small delay to let runner start
await asyncio.sleep(0.01)
await engine.initialize()
await engine.llm.queue_frame(LLMContextFrame(engine.context))
# Calculate total wait time:
# - Initial bot speech

View file

@ -42,7 +42,8 @@ ui/
## API Client
The `src/client/` directory is auto-generated from the backend OpenAPI spec:
The `src/client/` directory is auto-generated from the backend OpenAPI spec. Whenever you add a
new api route in backend, and wish to use it in the UI, generate the client using below command.
```bash
npm run generate-client

188
ui/package-lock.json generated
View file

@ -1,16 +1,15 @@
{
"name": "ui",
"version": "1.8.0",
"version": "1.10.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ui",
"version": "1.8.0",
"version": "1.10.0",
"dependencies": {
"@dagrejs/dagre": "^1.1.4",
"@hey-api/client-fetch": "^0.10.0",
"@livekit/components-react": "^2.9.0",
"@nangohq/frontend": "^0.60.3",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.2",
@ -33,7 +32,6 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"livekit-client": "^2.9.9",
"lucide-react": "^0.505.0",
"next": "^15.3.3",
"next-themes": "^0.4.6",
@ -1033,12 +1031,6 @@
"node": ">=6.9.0"
}
},
"node_modules/@bufbuild/protobuf": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.0.tgz",
"integrity": "sha512-QDdVFLoN93Zjg36NoQPZfsVH9tZew7wKDKyV5qRdj8ntT4wQCOradQjRaTdwMhWUYsgKsvCINKKm87FdEk96Ag==",
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
"node_modules/@dagrejs/dagre": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-1.1.4.tgz",
@ -2527,78 +2519,6 @@
"integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==",
"license": "MIT"
},
"node_modules/@livekit/components-core": {
"version": "0.12.2",
"resolved": "https://registry.npmjs.org/@livekit/components-core/-/components-core-0.12.2.tgz",
"integrity": "sha512-lSNEyWuJ94PNVR7uycpqxV5XG+GmPpRCRPFEiVnAKG8xbTKlBrlu6ruPgg3/dZG4oHCPOpTN7VPV4jbcUjFAoA==",
"license": "Apache-2.0",
"dependencies": {
"@floating-ui/dom": "1.6.13",
"loglevel": "1.9.1",
"rxjs": "7.8.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"livekit-client": "^2.9.5",
"tslib": "^2.6.2"
}
},
"node_modules/@livekit/components-core/node_modules/loglevel": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.1.tgz",
"integrity": "sha512-hP3I3kCrDIMuRwAwHltphhDM1r8i55H33GgqjXbrisuJhF4kRhW1dNuxsRklp4bXl8DSdLaNLuiL4A/LWRfxvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
},
"funding": {
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/loglevel"
}
},
"node_modules/@livekit/components-react": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.9.0.tgz",
"integrity": "sha512-HAY1d1N2n5e8KP3ogTGgvHpO4afDSKAt1pqDsHPumTYcNTGKuMtIfMc4CfUMrtVMQAggzouOTYfb/gC4gnaYiQ==",
"license": "Apache-2.0",
"dependencies": {
"@livekit/components-core": "0.12.2",
"clsx": "2.1.1",
"usehooks-ts": "3.1.1"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@livekit/krisp-noise-filter": "^0.2.12",
"livekit-client": "^2.9.5",
"react": ">=18",
"react-dom": ">=18",
"tslib": "^2.6.2"
},
"peerDependenciesMeta": {
"@livekit/krisp-noise-filter": {
"optional": true
}
}
},
"node_modules/@livekit/mutex": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@livekit/mutex/-/mutex-1.1.1.tgz",
"integrity": "sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==",
"license": "Apache-2.0"
},
"node_modules/@livekit/protocol": {
"version": "1.34.0",
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.34.0.tgz",
"integrity": "sha512-bU7pCLAMRVTVZb1KSxA46q55bhOc4iATrY/gccy2/oX1D57tiZEI+8wGRWHeDwBb0UwnABu6JXzC4tTFkdsaOg==",
"license": "Apache-2.0",
"dependencies": {
"@bufbuild/protobuf": "^1.10.0"
}
},
"node_modules/@nangohq/frontend": {
"version": "0.60.5",
"resolved": "https://registry.npmjs.org/@nangohq/frontend/-/frontend-0.60.5.tgz",
@ -16003,24 +15923,6 @@
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"license": "MIT"
},
"node_modules/livekit-client": {
"version": "2.9.9",
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.9.9.tgz",
"integrity": "sha512-Mrm9Z/kPmJP5r4EMbzXPfB27gRP+tZtU7Zzen2o8u6w5N6FxaqXerRFqtXddGXaVzZouJOAg51/5A9u3saC/2A==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@livekit/mutex": "1.1.1",
"@livekit/protocol": "1.34.0",
"events": "^3.3.0",
"loglevel": "^1.9.2",
"sdp-transform": "^2.15.0",
"ts-debounce": "^4.0.0",
"tslib": "2.8.1",
"typed-emitter": "^2.1.0",
"webrtc-adapter": "^9.0.1"
}
},
"node_modules/loader-runner": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz",
@ -16045,12 +15947,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash.debounce": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@ -16058,19 +15954,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/loglevel": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz",
"integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
},
"funding": {
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/loglevel"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@ -17754,15 +17637,6 @@
"queue-microtask": "^1.2.2"
}
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/safe-array-concat": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
@ -17907,21 +17781,6 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/sdp": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.0.tgz",
"integrity": "sha512-d7wDPgDV3DDiqulJjKiV2865wKsJ34YI+NDREbm+FySq6WuKOikwyNQcm+doLAZ1O6ltdO0SeKle2xMpN3Brgw==",
"license": "MIT"
},
"node_modules/sdp-transform": {
"version": "2.15.0",
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz",
"integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==",
"license": "MIT",
"bin": {
"sdp-verify": "checker.js"
}
},
"node_modules/secure-json-parse": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.0.0.tgz",
@ -18778,12 +18637,6 @@
"typescript": ">=4.8.4"
}
},
"node_modules/ts-debounce": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/ts-debounce/-/ts-debounce-4.0.0.tgz",
"integrity": "sha512-+1iDGY6NmOGidq7i7xZGA4cm8DAa6fqdYcvO5Z6yBevH++Bdo9Qt/mN0TzHUgcCcKv1gmh9+W5dHqz8pMWbCbg==",
"license": "MIT"
},
"node_modules/tsconfig-paths": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
@ -18935,15 +18788,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/typed-emitter": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz",
"integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==",
"license": "MIT",
"optionalDependencies": {
"rxjs": "*"
}
},
"node_modules/typescript": {
"version": "5.8.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz",
@ -19195,21 +19039,6 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/usehooks-ts": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-3.1.1.tgz",
"integrity": "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA==",
"license": "MIT",
"dependencies": {
"lodash.debounce": "^4.0.8"
},
"engines": {
"node": ">=16.15.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
@ -19360,19 +19189,6 @@
"node": ">=4.0"
}
},
"node_modules/webrtc-adapter": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.1.tgz",
"integrity": "sha512-1AQO+d4ElfVSXyzNVTOewgGT/tAomwwztX/6e3totvyyzXPvXIIuUUjAmyZGbKBKbZOXauuJooZm3g6IuFuiNQ==",
"license": "BSD-3-Clause",
"dependencies": {
"sdp": "^3.2.0"
},
"engines": {
"node": ">=6.0.0",
"npm": ">=3.10.0"
}
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",

View file

@ -13,7 +13,6 @@
"dependencies": {
"@dagrejs/dagre": "^1.1.4",
"@hey-api/client-fetch": "^0.10.0",
"@livekit/components-react": "^2.9.0",
"@nangohq/frontend": "^0.60.3",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.2",
@ -36,7 +35,6 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"livekit-client": "^2.9.9",
"lucide-react": "^0.505.0",
"next": "^15.3.3",
"next-themes": "^0.4.6",

View file

@ -0,0 +1,112 @@
"use client";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Textarea } from "@/components/ui/textarea";
import { type EndCallMessageType } from "../../config";
export interface EndCallToolConfigProps {
name: string;
onNameChange: (name: string) => void;
description: string;
onDescriptionChange: (description: string) => void;
messageType: EndCallMessageType;
onMessageTypeChange: (messageType: EndCallMessageType) => void;
customMessage: string;
onCustomMessageChange: (message: string) => void;
}
export function EndCallToolConfig({
name,
onNameChange,
description,
onDescriptionChange,
messageType,
onMessageTypeChange,
customMessage,
onCustomMessageChange,
}: EndCallToolConfigProps) {
return (
<Card>
<CardHeader>
<CardTitle>End Call Configuration</CardTitle>
<CardDescription>
Configure the behavior when the call ends
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-2">
<Label>Tool Name</Label>
<Label className="text-xs text-muted-foreground">
A descriptive name for this tool
</Label>
<Input
value={name}
onChange={(e) => onNameChange(e.target.value)}
placeholder="e.g., End Call"
/>
</div>
<div className="grid gap-2">
<Label>Description</Label>
<Label className="text-xs text-muted-foreground">
Helps the LLM understand when to use this tool
</Label>
<Textarea
value={description}
onChange={(e) => onDescriptionChange(e.target.value)}
placeholder="When should the AI end the call?"
rows={3}
/>
</div>
<div className="grid gap-4 pt-4 border-t">
<Label>Goodbye Message</Label>
<Label className="text-xs text-muted-foreground">
Choose whether to play a message before disconnecting
</Label>
<RadioGroup
value={messageType}
onValueChange={(v) => onMessageTypeChange(v as EndCallMessageType)}
className="space-y-3"
>
<label
htmlFor="none"
className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-muted/50 cursor-pointer"
>
<RadioGroupItem value="none" id="none" />
<div className="flex-1">
<span className="font-medium">No Message</span>
<p className="text-xs text-muted-foreground">
End the call immediately without any message
</p>
</div>
</label>
<div className="flex items-start space-x-3 p-3 border rounded-lg hover:bg-muted/50">
<RadioGroupItem value="custom" id="custom" className="mt-1" />
<label htmlFor="custom" className="flex-1 space-y-2 cursor-pointer">
<span className="font-medium">Custom Message</span>
<p className="text-xs text-muted-foreground">
Play a custom message before disconnecting
</p>
</label>
</div>
{messageType === "custom" && (
<div className="pl-8">
<Textarea
value={customMessage}
onChange={(e) => onCustomMessageChange(e.target.value)}
placeholder="e.g., Thank you for calling. Goodbye!"
rows={2}
/>
</div>
)}
</RadioGroup>
</div>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,169 @@
"use client";
import {
CredentialSelector,
type HttpMethod,
HttpMethodSelector,
KeyValueEditor,
type KeyValueItem,
ParameterEditor,
type ToolParameter,
UrlInput,
} from "@/components/http";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
export interface HttpApiToolConfigProps {
name: string;
onNameChange: (name: string) => void;
description: string;
onDescriptionChange: (description: string) => void;
httpMethod: HttpMethod;
onHttpMethodChange: (method: HttpMethod) => void;
url: string;
onUrlChange: (url: string) => void;
credentialUuid: string;
onCredentialUuidChange: (uuid: string) => void;
headers: KeyValueItem[];
onHeadersChange: (headers: KeyValueItem[]) => void;
parameters: ToolParameter[];
onParametersChange: (parameters: ToolParameter[]) => void;
timeoutMs: number;
onTimeoutMsChange: (timeout: number) => void;
}
export function HttpApiToolConfig({
name,
onNameChange,
description,
onDescriptionChange,
httpMethod,
onHttpMethodChange,
url,
onUrlChange,
credentialUuid,
onCredentialUuidChange,
headers,
onHeadersChange,
parameters,
onParametersChange,
timeoutMs,
onTimeoutMsChange,
}: HttpApiToolConfigProps) {
return (
<Card>
<CardHeader>
<CardTitle>Tool Configuration</CardTitle>
<CardDescription>
Configure the HTTP API endpoint and request settings
</CardDescription>
</CardHeader>
<CardContent>
<Tabs defaultValue="settings" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="settings">Settings</TabsTrigger>
<TabsTrigger value="auth">Authentication</TabsTrigger>
<TabsTrigger value="parameters">Parameters</TabsTrigger>
</TabsList>
<TabsContent value="settings" className="space-y-4 mt-4">
<div className="grid gap-2">
<Label>Tool Name</Label>
<Label className="text-xs text-muted-foreground">
Use a descriptive name, like &quot;Get Weather using API&quot; for a tool that fetches weather
</Label>
<Input
value={name}
onChange={(e) => onNameChange(e.target.value)}
placeholder="e.g., Book Appointment"
/>
</div>
<div className="grid gap-2">
<Label>Description</Label>
<Label className="text-xs text-muted-foreground">
Provide a description which makes it easy for LLM to understand what this tool does
</Label>
<Textarea
value={description}
onChange={(e) => onDescriptionChange(e.target.value)}
placeholder="What does this tool do?"
rows={3}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label>HTTP Method</Label>
<HttpMethodSelector
value={httpMethod}
onChange={onHttpMethodChange}
/>
</div>
<div className="grid gap-2">
<Label>Timeout (ms)</Label>
<Input
type="number"
value={timeoutMs}
onChange={(e) =>
onTimeoutMsChange(parseInt(e.target.value) || 5000)
}
min={1000}
max={30000}
/>
</div>
</div>
<div className="grid gap-2">
<Label>Endpoint URL</Label>
<UrlInput
value={url}
onChange={onUrlChange}
placeholder="https://api.example.com/appointments"
showValidation
/>
</div>
</TabsContent>
<TabsContent value="auth" className="space-y-4 mt-4">
<CredentialSelector
value={credentialUuid}
onChange={onCredentialUuidChange}
/>
</TabsContent>
<TabsContent value="parameters" className="space-y-4 mt-4">
<div className="grid gap-2">
<Label>Tool Parameters</Label>
<Label className="text-xs text-muted-foreground">
Define the parameters that the LLM will provide when calling this tool.
These will be sent as JSON body for POST/PUT/PATCH or as URL query params for GET/DELETE.
</Label>
<ParameterEditor
parameters={parameters}
onChange={onParametersChange}
/>
</div>
<div className="grid gap-2 pt-4 border-t">
<Label>Custom Headers</Label>
<Label className="text-xs text-muted-foreground">
Add custom headers to include in the request (optional)
</Label>
<KeyValueEditor
items={headers}
onChange={onHeadersChange}
keyPlaceholder="Header name"
valuePlaceholder="Header value"
addButtonText="Add Header"
/>
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,2 @@
export { EndCallToolConfig, type EndCallToolConfigProps } from "./EndCallToolConfig";
export { HttpApiToolConfig, type HttpApiToolConfigProps } from "./HttpApiToolConfig";

View file

@ -1,6 +1,6 @@
"use client";
import { ArrowLeft, Code, Globe, Loader2, Save } from "lucide-react";
import { ArrowLeft, Code, Loader2, Save } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
@ -9,6 +9,27 @@ import {
updateToolApiV1ToolsToolUuidPut,
} from "@/client/sdk.gen";
import type { ToolResponse } from "@/client/types.gen";
import { type HttpMethod, type KeyValueItem, type ToolParameter, validateUrl } from "@/components/http";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/lib/auth";
import {
type EndCallConfig,
type EndCallMessageType,
getCategoryConfig,
getToolTypeLabel,
renderToolIcon,
type ToolCategory,
} from "../config";
import { EndCallToolConfig, HttpApiToolConfig } from "./components";
// Extended HttpApiConfig with parameters (until client types are regenerated)
interface HttpApiConfigWithParams {
@ -19,38 +40,6 @@ interface HttpApiConfigWithParams {
parameters?: ToolParameter[];
timeout_ms?: number;
}
import {
CredentialSelector,
type HttpMethod,
HttpMethodSelector,
KeyValueEditor,
type KeyValueItem,
ParameterEditor,
type ToolParameter,
UrlInput,
validateUrl,
} from "@/components/http";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { useAuth } from "@/lib/auth";
export default function ToolDetailPage() {
const { toolUuid } = useParams<{ toolUuid: string }>();
@ -64,9 +53,11 @@ export default function ToolDetailPage() {
const [saveSuccess, setSaveSuccess] = useState(false);
const [showCodeDialog, setShowCodeDialog] = useState(false);
// Form state
// Common form state
const [name, setName] = useState("");
const [description, setDescription] = useState("");
// HTTP API form state
const [httpMethod, setHttpMethod] = useState<HttpMethod>("POST");
const [url, setUrl] = useState("");
const [credentialUuid, setCredentialUuid] = useState("");
@ -74,6 +65,10 @@ export default function ToolDetailPage() {
const [parameters, setParameters] = useState<ToolParameter[]>([]);
const [timeoutMs, setTimeoutMs] = useState(5000);
// End Call form state
const [endCallMessageType, setEndCallMessageType] = useState<EndCallMessageType>("none");
const [endCallCustomMessage, setEndCallCustomMessage] = useState("");
// Redirect if not authenticated
useEffect(() => {
if (!loading && !user) {
@ -112,37 +107,50 @@ export default function ToolDetailPage() {
setName(tool.name);
setDescription(tool.description || "");
const config = tool.definition?.config as HttpApiConfigWithParams | undefined;
if (config) {
setHttpMethod((config.method as HttpMethod) || "POST");
setUrl(config.url || "");
setCredentialUuid(config.credential_uuid || "");
setTimeoutMs(config.timeout_ms || 5000);
// Convert headers object to array
if (config.headers) {
setHeaders(
Object.entries(config.headers).map(([key, value]) => ({
key,
value: value as string,
}))
);
if (tool.category === "end_call") {
// Populate end call specific fields
const config = tool.definition?.config as EndCallConfig | undefined;
if (config) {
setEndCallMessageType(config.messageType || "none");
setEndCallCustomMessage(config.customMessage || "");
} else {
setHeaders([]);
setEndCallMessageType("none");
setEndCallCustomMessage("");
}
} else {
// Populate HTTP API specific fields
const config = tool.definition?.config as HttpApiConfigWithParams | undefined;
if (config) {
setHttpMethod((config.method as HttpMethod) || "POST");
setUrl(config.url || "");
setCredentialUuid(config.credential_uuid || "");
setTimeoutMs(config.timeout_ms || 5000);
// Load parameters
if (config.parameters && Array.isArray(config.parameters)) {
setParameters(
config.parameters.map((p: ToolParameter) => ({
name: p.name || "",
type: p.type || "string",
description: p.description || "",
required: p.required ?? true,
}))
);
} else {
setParameters([]);
// Convert headers object to array
if (config.headers) {
setHeaders(
Object.entries(config.headers).map(([key, value]) => ({
key,
value: value as string,
}))
);
} else {
setHeaders([]);
}
// Load parameters
if (config.parameters && Array.isArray(config.parameters)) {
setParameters(
config.parameters.map((p: ToolParameter) => ({
name: p.name || "",
type: p.type || "string",
description: p.description || "",
required: p.required ?? true,
}))
);
} else {
setParameters([]);
}
}
}
};
@ -152,18 +160,23 @@ export default function ToolDetailPage() {
}, [fetchTool]);
const handleSave = async () => {
// Validate URL
const urlValidation = validateUrl(url);
if (!urlValidation.valid) {
setError(urlValidation.error || "Invalid URL");
return;
}
if (!tool) return;
// Validate parameters have names
const invalidParams = parameters.filter((p) => !p.name.trim());
if (invalidParams.length > 0) {
setError("All parameters must have a name");
return;
// Validation based on tool type
if (tool.category !== "end_call") {
// Validate URL for HTTP API tools
const urlValidation = validateUrl(url);
if (!urlValidation.valid) {
setError(urlValidation.error || "Invalid URL");
return;
}
// Validate parameters have names
const invalidParams = parameters.filter((p) => !p.name.trim());
if (invalidParams.length > 0) {
setError("All parameters must have a name");
return;
}
}
try {
@ -172,36 +185,52 @@ export default function ToolDetailPage() {
setSaveSuccess(false);
const accessToken = await getAccessToken();
// Convert headers array to object
const headersObject: Record<string, string> = {};
headers.filter((h) => h.key && h.value).forEach((h) => {
headersObject[h.key] = h.value;
});
let requestBody;
// Filter out empty parameters
const validParameters = parameters.filter((p) => p.name.trim());
// Build the request body (cast needed until client types are regenerated)
const requestBody = {
name,
description: description || undefined,
definition: {
schema_version: 1,
type: "http_api",
config: {
method: httpMethod,
url,
credential_uuid: credentialUuid || undefined,
headers:
Object.keys(headersObject).length > 0
? headersObject
: undefined,
parameters:
validParameters.length > 0 ? validParameters : undefined,
timeout_ms: timeoutMs,
if (tool.category === "end_call") {
// Build end call request body
requestBody = {
name,
description: description || undefined,
definition: {
schema_version: 1,
type: "end_call",
config: {
messageType: endCallMessageType,
customMessage: endCallMessageType === "custom" ? endCallCustomMessage : undefined,
},
},
},
};
};
} else {
// Build HTTP API request body
const headersObject: Record<string, string> = {};
headers.filter((h) => h.key && h.value).forEach((h) => {
headersObject[h.key] = h.value;
});
const validParameters = parameters.filter((p) => p.name.trim());
requestBody = {
name,
description: description || undefined,
definition: {
schema_version: 1,
type: "http_api",
config: {
method: httpMethod,
url,
credential_uuid: credentialUuid || undefined,
headers:
Object.keys(headersObject).length > 0
? headersObject
: undefined,
parameters:
validParameters.length > 0 ? validParameters : undefined,
timeout_ms: timeoutMs,
},
},
};
}
const response = await updateToolApiV1ToolsToolUuidPut({
path: { tool_uuid: toolUuid },
@ -301,6 +330,9 @@ const data = await response.json();`;
);
}
const isEndCallTool = tool.category === "end_call";
const categoryConfig = getCategoryConfig(tool.category as ToolCategory);
return (
<div className="min-h-screen bg-background">
<div className="container mx-auto px-4 py-8">
@ -320,27 +352,29 @@ const data = await response.json();`;
<div
className="w-10 h-10 rounded-lg flex items-center justify-center"
style={{
backgroundColor: tool.icon_color || "#3B82F6",
backgroundColor: tool.icon_color || categoryConfig?.iconColor || "#3B82F6",
}}
>
<Globe className="w-5 h-5 text-white" />
{renderToolIcon(tool.category)}
</div>
<div>
<h1 className="text-xl font-bold">{name}</h1>
<p className="text-sm text-muted-foreground">
HTTP API Tool
{getToolTypeLabel(tool.category)}
</p>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => setShowCodeDialog(true)}
>
<Code className="w-4 h-4 mr-2" />
View Code
</Button>
{!isEndCallTool && (
<Button
variant="outline"
onClick={() => setShowCodeDialog(true)}
>
<Code className="w-4 h-4 mr-2" />
View Code
</Button>
)}
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? (
<>
@ -369,121 +403,41 @@ const data = await response.json();`;
</div>
)}
<Card>
<CardHeader>
<CardTitle>Tool Configuration</CardTitle>
<CardDescription>
Configure the HTTP API endpoint and request settings
</CardDescription>
</CardHeader>
<CardContent>
<Tabs defaultValue="settings" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="settings">Settings</TabsTrigger>
<TabsTrigger value="auth">Authentication</TabsTrigger>
<TabsTrigger value="parameters">Parameters</TabsTrigger>
</TabsList>
<TabsContent value="settings" className="space-y-4 mt-4">
<div className="grid gap-2">
<Label>Tool Name</Label>
<Label className="text-xs text-muted-foreground">
Use a descriptive name, like &quot;Get Weather using API&quot; for a tool that fetches weather
</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Book Appointment"
/>
</div>
<div className="grid gap-2">
<Label>Description</Label>
<Label className="text-xs text-muted-foreground">
Provide a description which makes it easy for LLM to understand what this tool does
</Label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What does this tool do?"
rows={3}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label>HTTP Method</Label>
<HttpMethodSelector
value={httpMethod}
onChange={setHttpMethod}
/>
</div>
<div className="grid gap-2">
<Label>Timeout (ms)</Label>
<Input
type="number"
value={timeoutMs}
onChange={(e) =>
setTimeoutMs(parseInt(e.target.value) || 5000)
}
min={1000}
max={30000}
/>
</div>
</div>
<div className="grid gap-2">
<Label>Endpoint URL</Label>
<UrlInput
value={url}
onChange={setUrl}
placeholder="https://api.example.com/appointments"
showValidation
/>
</div>
</TabsContent>
<TabsContent value="auth" className="space-y-4 mt-4">
<CredentialSelector
value={credentialUuid}
onChange={setCredentialUuid}
/>
</TabsContent>
<TabsContent value="parameters" className="space-y-4 mt-4">
<div className="grid gap-2">
<Label>Tool Parameters</Label>
<Label className="text-xs text-muted-foreground">
Define the parameters that the LLM will provide when calling this tool.
These will be sent as JSON body for POST/PUT/PATCH or as URL query params for GET/DELETE.
</Label>
<ParameterEditor
parameters={parameters}
onChange={setParameters}
/>
</div>
<div className="grid gap-2 pt-4 border-t">
<Label>Custom Headers</Label>
<Label className="text-xs text-muted-foreground">
Add custom headers to include in the request (optional)
</Label>
<KeyValueEditor
items={headers}
onChange={setHeaders}
keyPlaceholder="Header name"
valuePlaceholder="Header value"
addButtonText="Add Header"
/>
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
{isEndCallTool ? (
<EndCallToolConfig
name={name}
onNameChange={setName}
description={description}
onDescriptionChange={setDescription}
messageType={endCallMessageType}
onMessageTypeChange={setEndCallMessageType}
customMessage={endCallCustomMessage}
onCustomMessageChange={setEndCallCustomMessage}
/>
) : (
<HttpApiToolConfig
name={name}
onNameChange={setName}
description={description}
onDescriptionChange={setDescription}
httpMethod={httpMethod}
onHttpMethodChange={setHttpMethod}
url={url}
onUrlChange={setUrl}
credentialUuid={credentialUuid}
onCredentialUuidChange={setCredentialUuid}
headers={headers}
onHeadersChange={setHeaders}
parameters={parameters}
onParametersChange={setParameters}
timeoutMs={timeoutMs}
onTimeoutMsChange={setTimeoutMs}
/>
)}
</div>
</div>
{/* Code View Dialog */}
{/* Code View Dialog (only for HTTP API tools) */}
<Dialog open={showCodeDialog} onOpenChange={setShowCodeDialog}>
<DialogContent className="max-w-2xl">
<DialogHeader>

164
ui/src/app/tools/config.tsx Normal file
View file

@ -0,0 +1,164 @@
"use client";
import { Cog, Globe, type LucideIcon,PhoneOff, Puzzle } from "lucide-react";
import { type ReactNode } from "react";
export type ToolCategory = "http_api" | "end_call" | "native" | "integration";
export type EndCallMessageType = "none" | "custom";
export interface ToolCategoryConfig {
value: ToolCategory;
label: string;
description: string;
icon: LucideIcon;
iconName: string; // String name for storing in database
iconColor: string;
disabled?: boolean;
autoFill?: {
name: string;
description: string;
};
}
export const TOOL_CATEGORIES: ToolCategoryConfig[] = [
{
value: "http_api",
label: "External HTTP API",
description: "Make HTTP requests to external APIs",
icon: Globe,
iconName: "globe",
iconColor: "#3B82F6",
},
{
value: "end_call",
label: "End Call",
description: "End the call when conditions are met",
icon: PhoneOff,
iconName: "phone-off",
iconColor: "#EF4444",
autoFill: {
name: "End Call",
description: "End the call when either user asks to disconnect the call, or when you believe its time to end the conversation",
},
},
{
value: "native",
label: "Native (Coming Soon)",
description: "Built-in tools like call transfer, DTMF input",
icon: Cog,
iconName: "cog",
iconColor: "#6B7280",
disabled: true,
},
{
value: "integration",
label: "Integration (Coming Soon)",
description: "Third-party integrations like Google Calendar",
icon: Puzzle,
iconName: "puzzle",
iconColor: "#8B5CF6",
disabled: true,
},
];
export function getCategoryConfig(category: ToolCategory): ToolCategoryConfig | undefined {
return TOOL_CATEGORIES.find(c => c.value === category);
}
export function getToolIcon(category: string): LucideIcon {
const config = TOOL_CATEGORIES.find(c => c.value === category);
return config?.icon ?? Globe;
}
export function getToolIconColor(category: string, fallbackColor?: string): string {
const config = TOOL_CATEGORIES.find(c => c.value === category);
return config?.iconColor ?? fallbackColor ?? "#3B82F6";
}
export function renderToolIcon(category: string, className: string = "w-5 h-5 text-white"): ReactNode {
const Icon = getToolIcon(category);
return <Icon className={className} />;
}
export function getToolTypeLabel(category: string): string {
switch (category) {
case "end_call":
return "End Call Tool";
case "http_api":
return "HTTP API Tool";
case "native":
return "Native Tool";
case "integration":
return "Integration Tool";
default:
return "Tool";
}
}
// End Call tool specific configuration
export interface EndCallConfig {
messageType: EndCallMessageType;
customMessage?: string;
}
export const DEFAULT_END_CALL_CONFIG: EndCallConfig = {
messageType: "none",
customMessage: "",
};
// Tool definition types for different categories
export interface HttpApiToolDefinition {
schema_version: number;
type: "http_api";
config: {
method: string;
url: string;
headers?: Record<string, string>;
credential_uuid?: string;
parameters?: Array<{
name: string;
type: string;
description: string;
required: boolean;
}>;
timeout_ms?: number;
};
}
export interface EndCallToolDefinition {
schema_version: number;
type: "end_call";
config: EndCallConfig;
}
export type ToolDefinition = HttpApiToolDefinition | EndCallToolDefinition;
export function createEndCallDefinition(config: EndCallConfig): EndCallToolDefinition {
return {
schema_version: 1,
type: "end_call",
config,
};
}
export function createHttpApiDefinition(): HttpApiToolDefinition {
return {
schema_version: 1,
type: "http_api",
config: {
method: "POST",
url: "",
},
};
}
export function createToolDefinition(category: ToolCategory): ToolDefinition {
switch (category) {
case "end_call":
return createEndCallDefinition(DEFAULT_END_CALL_CONFIG);
case "http_api":
default:
return createHttpApiDefinition();
}
}

View file

@ -1,6 +1,6 @@
"use client";
import { Globe, Plus, Search, Trash2 } from "lucide-react";
import { Plus, RotateCcw, Search, Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
@ -8,6 +8,7 @@ import {
createToolApiV1ToolsPost,
deleteToolApiV1ToolsToolUuidDelete,
listToolsApiV1ToolsGet,
unarchiveToolApiV1ToolsToolUuidUnarchivePost,
} from "@/client/sdk.gen";
import type { ToolResponse } from "@/client/types.gen";
import { Badge } from "@/components/ui/badge";
@ -39,27 +40,13 @@ import {
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/lib/auth";
type ToolCategory = "http_api" | "native" | "integration";
const TOOL_CATEGORIES: { value: ToolCategory; label: string; description: string; disabled?: boolean }[] = [
{
value: "http_api",
label: "External HTTP API",
description: "Make HTTP requests to external APIs",
},
{
value: "native",
label: "Native (Coming Soon)",
description: "Built-in tools like call transfer, DTMF input",
disabled: true,
},
{
value: "integration",
label: "Integration (Coming Soon)",
description: "Third-party integrations like Google Calendar",
disabled: true,
},
];
import {
createToolDefinition,
getCategoryConfig,
renderToolIcon,
TOOL_CATEGORIES,
type ToolCategory,
} from "./config";
export default function ToolsPage() {
const { user, getAccessToken, redirectToLogin, loading } = useAuth();
@ -74,6 +61,7 @@ export default function ToolsPage() {
const [newToolCategory, setNewToolCategory] = useState<ToolCategory>("http_api");
const [isCreating, setIsCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [createError, setCreateError] = useState<string | null>(null);
// Redirect if not authenticated
useEffect(() => {
@ -94,6 +82,9 @@ export default function ToolsPage() {
headers: {
Authorization: `Bearer ${accessToken}`,
},
query: {
status: "active,archived",
},
});
if (response.data) {
@ -113,36 +104,36 @@ export default function ToolsPage() {
const handleCreateTool = async () => {
if (!newToolName.trim()) {
setError("Please enter a name for the tool");
setCreateError("Please enter a name for the tool");
return;
}
try {
setIsCreating(true);
setError(null);
setCreateError(null);
const accessToken = await getAccessToken();
const categoryConfig = getCategoryConfig(newToolCategory);
const response = await createToolApiV1ToolsPost({
body: {
name: newToolName,
description: newToolDescription || undefined,
category: newToolCategory,
icon: "globe",
icon_color: "#3B82F6",
definition: {
schema_version: 1,
type: newToolCategory,
config: {
method: "POST",
url: "",
},
},
icon: categoryConfig?.iconName || "globe",
icon_color: categoryConfig?.iconColor || "#3B82F6",
definition: createToolDefinition(newToolCategory),
},
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (response.error) {
const errorDetail = (response.error as { detail?: string })?.detail;
setCreateError(errorDetail || "Failed to create tool");
return;
}
if (response.data) {
setIsCreateDialogOpen(false);
setNewToolName("");
@ -151,8 +142,23 @@ export default function ToolsPage() {
// Navigate to the new tool's detail page
router.push(`/tools/${response.data.tool_uuid}`);
}
} catch (err) {
setError("Failed to create tool");
} catch (err: unknown) {
let errorMessage = "Failed to create tool";
if (err && typeof err === "object") {
const errObj = err as Record<string, unknown>;
// Handle API client error response
if (errObj.error && typeof errObj.error === "object") {
const errorData = errObj.error as Record<string, unknown>;
if (typeof errorData.detail === "string") {
errorMessage = errorData.detail;
}
}
// Handle standard Error objects
else if (errObj.message && typeof errObj.message === "string") {
errorMessage = errObj.message;
}
}
setCreateError(errorMessage);
console.error("Error creating tool:", err);
} finally {
setIsCreating(false);
@ -178,8 +184,31 @@ export default function ToolsPage() {
fetchTools();
} catch (err) {
setError("Failed to delete tool");
console.error("Error deleting tool:", err);
setError("Failed to archive tool");
console.error("Error archiving tool:", err);
}
};
const handleUnarchiveTool = async (toolUuid: string, e: React.MouseEvent) => {
e.stopPropagation();
try {
setError(null);
const accessToken = await getAccessToken();
await unarchiveToolApiV1ToolsToolUuidUnarchivePost({
path: {
tool_uuid: toolUuid,
},
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
fetchTools();
} catch (err) {
setError("Failed to unarchive tool");
console.error("Error unarchiving tool:", err);
}
};
@ -189,10 +218,15 @@ export default function ToolsPage() {
tool.description?.toLowerCase().includes(searchQuery.toLowerCase())
);
const activeTools = filteredTools.filter((tool) => tool.status === "active");
const archivedTools = filteredTools.filter((tool) => tool.status === "archived");
const getCategoryBadge = (category: string) => {
switch (category) {
case "http_api":
return <Badge variant="default">HTTP API</Badge>;
case "end_call":
return <Badge variant="destructive">End Call</Badge>;
case "native":
return <Badge variant="secondary">Native</Badge>;
case "integration":
@ -233,7 +267,7 @@ export default function ToolsPage() {
<div className="mb-8">
<h1 className="text-3xl font-bold mb-2">Tools</h1>
<p className="text-muted-foreground">
Manage reusable HTTP API tools that can be used across your workflows
Manage reusable tools that can be used across your workflows
</p>
</div>
@ -249,7 +283,7 @@ export default function ToolsPage() {
<div>
<CardTitle>Your Tools</CardTitle>
<CardDescription>
Create and manage HTTP API tools for your organization
Create and manage tools for your organization
</CardDescription>
</div>
<Button onClick={() => setIsCreateDialogOpen(true)}>
@ -285,9 +319,9 @@ export default function ToolsPage() {
</div>
))}
</div>
) : filteredTools.length === 0 ? (
) : activeTools.length === 0 && archivedTools.length === 0 ? (
<div className="text-center py-12">
<Globe className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
{renderToolIcon("http_api", "w-12 h-12 text-muted-foreground mx-auto mb-4")}
<p className="text-muted-foreground mb-4">
{searchQuery
? "No tools match your search"
@ -300,53 +334,123 @@ export default function ToolsPage() {
)}
</div>
) : (
<div className="space-y-4">
{filteredTools.map((tool) => (
<div
key={tool.tool_uuid}
className="flex items-center justify-between p-4 border rounded-lg hover:bg-muted/50 cursor-pointer transition-colors"
onClick={() =>
router.push(`/tools/${tool.tool_uuid}`)
}
>
<div className="flex items-center gap-4">
<>
{/* Active Tools */}
{activeTools.length > 0 ? (
<div className="space-y-4">
{activeTools.map((tool) => (
<div
className="w-10 h-10 rounded-lg flex items-center justify-center"
style={{
backgroundColor:
tool.icon_color || "#3B82F6",
}}
key={tool.tool_uuid}
className="flex items-center justify-between p-4 border rounded-lg hover:bg-muted/50 cursor-pointer transition-colors"
onClick={() =>
router.push(`/tools/${tool.tool_uuid}`)
}
>
<Globe className="w-5 h-5 text-white" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium">
{tool.name}
</span>
{getCategoryBadge(tool.category)}
{getStatusBadge(tool.status)}
<div className="flex items-center gap-4">
<div
className="w-10 h-10 rounded-lg flex items-center justify-center"
style={{
backgroundColor:
tool.icon_color || getCategoryConfig(tool.category as ToolCategory)?.iconColor || "#3B82F6",
}}
>
{renderToolIcon(tool.category)}
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium">
{tool.name}
</span>
{getCategoryBadge(tool.category)}
</div>
{tool.description && (
<p className="text-sm text-muted-foreground mt-1">
{tool.description}
</p>
)}
</div>
</div>
{tool.description && (
<p className="text-sm text-muted-foreground mt-1">
{tool.description}
</p>
)}
<Button
variant="ghost"
size="sm"
onClick={(e) =>
handleDeleteTool(tool.tool_uuid, e)
}
className="text-destructive hover:text-destructive/90"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={(e) =>
handleDeleteTool(tool.tool_uuid, e)
}
className="text-destructive hover:text-destructive/90"
>
<Trash2 className="w-4 h-4" />
))}
</div>
) : !searchQuery ? (
<div className="text-center py-8">
<p className="text-muted-foreground mb-4">
No active tools
</p>
<Button onClick={() => setIsCreateDialogOpen(true)}>
Create Your First Tool
</Button>
</div>
))}
</div>
) : null}
{/* Archived Tools */}
{archivedTools.length > 0 && (
<div className="mt-8">
<h3 className="text-lg font-semibold text-muted-foreground mb-4">
Archived Tools
</h3>
<div className="space-y-4">
{archivedTools.map((tool) => (
<div
key={tool.tool_uuid}
className="flex items-center justify-between p-4 border rounded-lg hover:bg-muted/50 cursor-pointer transition-colors opacity-60"
onClick={() =>
router.push(`/tools/${tool.tool_uuid}`)
}
>
<div className="flex items-center gap-4">
<div
className="w-10 h-10 rounded-lg flex items-center justify-center"
style={{
backgroundColor:
tool.icon_color || getCategoryConfig(tool.category as ToolCategory)?.iconColor || "#3B82F6",
}}
>
{renderToolIcon(tool.category)}
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium">
{tool.name}
</span>
{getCategoryBadge(tool.category)}
{getStatusBadge(tool.status)}
</div>
{tool.description && (
<p className="text-sm text-muted-foreground mt-1">
{tool.description}
</p>
)}
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={(e) =>
handleUnarchiveTool(tool.tool_uuid, e)
}
className="text-primary hover:text-primary/90"
title="Restore tool"
>
<RotateCcw className="w-4 h-4" />
</Button>
</div>
))}
</div>
</div>
)}
</>
)}
</CardContent>
</Card>
@ -354,7 +458,10 @@ export default function ToolsPage() {
</div>
{/* Create Tool Dialog */}
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<Dialog open={isCreateDialogOpen} onOpenChange={(open) => {
setIsCreateDialogOpen(open);
if (open) setCreateError(null);
}}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Tool</DialogTitle>
@ -367,7 +474,15 @@ export default function ToolsPage() {
<Label>Tool Type</Label>
<Select
value={newToolCategory}
onValueChange={(v) => setNewToolCategory(v as ToolCategory)}
onValueChange={(v) => {
const category = v as ToolCategory;
setNewToolCategory(category);
const categoryConfig = getCategoryConfig(category);
if (categoryConfig?.autoFill) {
setNewToolName(categoryConfig.autoFill.name);
setNewToolDescription(categoryConfig.autoFill.description);
}
}}
>
<SelectTrigger className="w-full">
<SelectValue />
@ -385,7 +500,7 @@ export default function ToolsPage() {
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{TOOL_CATEGORIES.find(c => c.value === newToolCategory)?.description}
{getCategoryConfig(newToolCategory)?.description}
</p>
</div>
<div className="grid gap-2">
@ -413,6 +528,11 @@ export default function ToolsPage() {
/>
</div>
</div>
{createError && (
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-lg text-destructive text-sm">
{createError}
</div>
)}
<DialogFooter>
<Button
variant="outline"

File diff suppressed because one or more lines are too long

View file

@ -184,7 +184,11 @@ export type CreateToolRequest = {
category?: string;
icon?: string | null;
icon_color?: string | null;
definition: ToolDefinition;
definition: ({
type?: 'http_api';
} & HttpApiToolDefinition) | ({
type?: 'end_call';
} & EndCallToolDefinition);
};
export type CreateWorkflowRequest = {
@ -349,6 +353,38 @@ export type EmbedTokenResponse = {
embed_script: string;
};
/**
* Configuration for End Call tools.
*/
export type EndCallConfig = {
/**
* Type of goodbye message
*/
messageType?: 'none' | 'custom';
/**
* Custom message to play before ending the call
*/
customMessage?: string | null;
};
/**
* Tool definition for End Call tools.
*/
export type EndCallToolDefinition = {
/**
* Schema version
*/
schema_version?: number;
/**
* Tool type
*/
type: 'end_call';
/**
* End Call configuration
*/
config: EndCallConfig;
};
export type FileMetadataResponse = {
key: string;
metadata: {
@ -392,6 +428,24 @@ export type HttpApiConfig = {
timeout_ms?: number | null;
};
/**
* Tool definition for HTTP API tools.
*/
export type HttpApiToolDefinition = {
/**
* Schema version
*/
schema_version?: number;
/**
* Tool type
*/
type: 'http_api';
/**
* HTTP API configuration
*/
config: HttpApiConfig;
};
/**
* Request payload for superadmin impersonation.
*
@ -572,24 +626,6 @@ export type TestSessionResponse = {
completed_at: string | null;
};
/**
* Tool definition schema.
*/
export type ToolDefinition = {
/**
* Schema version for compatibility
*/
schema_version?: number;
/**
* Tool type (http_api)
*/
type: string;
/**
* Tool configuration
*/
config: HttpApiConfig;
};
/**
* A parameter that the tool accepts.
*/
@ -706,7 +742,11 @@ export type UpdateToolRequest = {
description?: string | null;
icon?: string | null;
icon_color?: string | null;
definition?: ToolDefinition | null;
definition?: (({
type?: 'http_api';
} & HttpApiToolDefinition) | ({
type?: 'end_call';
} & EndCallToolDefinition)) | null;
status?: string | null;
};
@ -2802,6 +2842,41 @@ export type UpdateToolApiV1ToolsToolUuidPutResponses = {
export type UpdateToolApiV1ToolsToolUuidPutResponse = UpdateToolApiV1ToolsToolUuidPutResponses[keyof UpdateToolApiV1ToolsToolUuidPutResponses];
export type UnarchiveToolApiV1ToolsToolUuidUnarchivePostData = {
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path: {
tool_uuid: string;
};
query?: never;
url: '/api/v1/tools/{tool_uuid}/unarchive';
};
export type UnarchiveToolApiV1ToolsToolUuidUnarchivePostErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type UnarchiveToolApiV1ToolsToolUuidUnarchivePostError = UnarchiveToolApiV1ToolsToolUuidUnarchivePostErrors[keyof UnarchiveToolApiV1ToolsToolUuidUnarchivePostErrors];
export type UnarchiveToolApiV1ToolsToolUuidUnarchivePostResponses = {
/**
* Successful Response
*/
200: ToolResponse;
};
export type UnarchiveToolApiV1ToolsToolUuidUnarchivePostResponse = UnarchiveToolApiV1ToolsToolUuidUnarchivePostResponses[keyof UnarchiveToolApiV1ToolsToolUuidUnarchivePostResponses];
export type GetIntegrationsApiV1IntegrationGetData = {
body?: never;
headers?: {

View file

@ -1,9 +1,10 @@
"use client";
import { ExternalLink, Globe, Loader2 } from "lucide-react";
import { ExternalLink, Loader2 } from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { renderToolIcon } from "@/app/tools/config";
import { listToolsApiV1ToolsGet } from "@/client/sdk.gen";
import type { ToolResponse } from "@/client/types.gen";
import { Button } from "@/components/ui/button";
@ -123,7 +124,7 @@ export function ToolSelector({
backgroundColor: tool.icon_color || "#3B82F6",
}}
>
<Globe className="h-3 w-3 text-white" />
{renderToolIcon(tool.category, "h-3 w-3 text-white")}
</div>
<div className="flex flex-col min-w-0 flex-1">
<span className="text-sm font-medium truncate">

View file

@ -250,9 +250,13 @@ export default function CustomEdge(props: CustomEdgeProps) {
{/* Show full EdgeLabel when selected or hovered, otherwise show simple label */}
{(selected || isHovered) ? (
<div className={cn(
"flex flex-col gap-2 bg-card rounded-lg border shadow-xl min-w-[220px]",
"flex flex-col gap-2 bg-card rounded-lg border min-w-[220px]",
"animate-in fade-in zoom-in duration-200",
data?.invalid ? "border-destructive/50 shadow-[0_0_15px_rgba(239,68,68,0.3)]" : "border-border"
data?.invalid
? "border-destructive/50 shadow-[0_0_15px_rgba(239,68,68,0.3)]"
: selected
? "border-primary ring-2 ring-primary/40 shadow-[0_0_20px_rgba(59,130,246,0.5)]"
: "border-border shadow-xl"
)}>
{/* Header with label */}
<div className={cn(

View file

@ -19,8 +19,8 @@ export const BaseNode = forwardRef<
// Border styling
"border-border",
className,
// Selected state
selected ? "border-muted-foreground shadow-lg" : "",
// Selected state - prominent halo effect
selected ? "border-primary ring-2 ring-primary/40 shadow-[0_0_20px_rgba(59,130,246,0.5)]" : "",
// Invalid state
invalid ? "border-destructive shadow-[0_0_10px_rgba(239,68,68,0.3)]" : "",
// Hovered through edge takes precedence over selected through edge