feat: enable dynamic transfer destination resolution

This commit is contained in:
Sabiha Khan 2026-07-10 14:11:05 +05:30
parent f3bcf24370
commit 6ce1bbcbf8
10 changed files with 1476 additions and 52 deletions

View file

@ -1,14 +1,23 @@
"use client";
import type { RecordingResponseSchema } from "@/client/types.gen";
import { CredentialSelector, ParameterEditor, type ToolParameter } from "@/components/http";
import { RecordingSelect, StaticTextWarning } from "@/components/flow/TextOrAudioInput";
import { Button } from "@/components/ui/button";
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { Plus, Trash2 } from "lucide-react";
import { type EndCallMessageType } from "../../config";
import {
type EndCallMessageType,
type TransferApprovedRouteRow,
type TransferResolverPolicy,
} from "../../config";
export interface TransferCallToolConfigProps {
name: string;
@ -26,6 +35,24 @@ export interface TransferCallToolConfigProps {
recordings?: RecordingResponseSchema[];
timeout?: number; // Make optional to match API type
onTimeoutChange: (timeout: number) => void;
resolverEnabled: boolean;
onResolverEnabledChange: (enabled: boolean) => void;
resolverUrl: string;
onResolverUrlChange: (url: string) => void;
resolverCredentialUuid: string;
onResolverCredentialUuidChange: (uuid: string) => void;
resolverTimeoutMs: number;
onResolverTimeoutMsChange: (timeoutMs: number) => void;
resolverWaitMessage: string;
onResolverWaitMessageChange: (message: string) => void;
resolverPolicy: TransferResolverPolicy;
onResolverPolicyChange: (policy: TransferResolverPolicy) => void;
approvedRoutes: TransferApprovedRouteRow[];
onApprovedRoutesChange: (routes: TransferApprovedRouteRow[]) => void;
fallbackRoute: string;
onFallbackRouteChange: (route: string) => void;
parameters: ToolParameter[];
onParametersChange: (parameters: ToolParameter[]) => void;
}
export function TransferCallToolConfig({
@ -44,7 +71,52 @@ export function TransferCallToolConfig({
recordings = [],
timeout,
onTimeoutChange,
resolverEnabled,
onResolverEnabledChange,
resolverUrl,
onResolverUrlChange,
resolverCredentialUuid,
onResolverCredentialUuidChange,
resolverTimeoutMs,
onResolverTimeoutMsChange,
resolverWaitMessage,
onResolverWaitMessageChange,
resolverPolicy,
onResolverPolicyChange,
approvedRoutes,
onApprovedRoutesChange,
fallbackRoute,
onFallbackRouteChange,
parameters,
onParametersChange,
}: TransferCallToolConfigProps) {
const updateRoute = (
index: number,
patch: Partial<TransferApprovedRouteRow>,
) => {
onApprovedRoutesChange(
approvedRoutes.map((route, i) =>
i === index ? { ...route, ...patch } : route,
),
);
};
const removeRoute = (index: number) => {
onApprovedRoutesChange(approvedRoutes.filter((_, i) => i !== index));
};
const addRoute = () => {
onApprovedRoutesChange([
...approvedRoutes,
{
key: "",
destination: "",
message: "",
timeout_seconds: 30,
},
]);
};
return (
<Card>
<CardHeader>
@ -186,6 +258,203 @@ export function TransferCallToolConfig({
Default: 30 seconds
</Label>
</div>
<div className="grid gap-4 pt-4 border-t">
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<Label>Dynamic Transfer Resolver</Label>
<p className="text-xs text-muted-foreground">
Resolve the destination from an HTTP endpoint when the transfer tool is called.
</p>
</div>
<Switch
checked={resolverEnabled}
onCheckedChange={onResolverEnabledChange}
/>
</div>
{resolverEnabled && (
<div className="space-y-5">
<div className="grid gap-2">
<Label>Resolver URL</Label>
<Input
value={resolverUrl}
onChange={(e) => onResolverUrlChange(e.target.value)}
placeholder="https://crm.example.com/resolve-transfer"
/>
</div>
<CredentialSelector
value={resolverCredentialUuid}
onChange={onResolverCredentialUuidChange}
label="Resolver Credential (Optional)"
description="Select a credential for the resolver endpoint, or leave empty for no auth."
/>
<div className="grid gap-2">
<Label>Resolver Timeout</Label>
<Input
type="number"
value={resolverTimeoutMs}
onChange={(e) => {
const value = parseInt(e.target.value) || 3000;
onResolverTimeoutMsChange(Math.min(Math.max(value, 500), 5000));
}}
min="500"
max="5000"
className="w-36"
/>
<Label className="text-xs text-muted-foreground">
Default: 3000 ms. Maximum: 5000 ms.
</Label>
</div>
<div className="grid gap-2">
<Label>Resolver Wait Message</Label>
<Textarea
value={resolverWaitMessage}
onChange={(e) => onResolverWaitMessageChange(e.target.value)}
placeholder="One moment while I find the right team."
rows={2}
/>
</div>
<div className="grid gap-2">
<Label>Resolver Policy</Label>
<Select
value={resolverPolicy}
onValueChange={(value) =>
onResolverPolicyChange(value as TransferResolverPolicy)
}
>
<SelectTrigger>
<SelectValue placeholder="Select policy" />
</SelectTrigger>
<SelectContent>
<SelectItem value="approved_routes_only">
Approved routes only
</SelectItem>
<SelectItem value="approved_routes_or_static_fallback">
Approved routes or static fallback
</SelectItem>
<SelectItem value="allow_raw_destination">
Allow raw destination
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-3">
<div>
<Label>Resolver Arguments</Label>
<p className="text-xs text-muted-foreground">
Values the agent should pass when calling this transfer tool, such as state, department, or reason.
</p>
</div>
<ParameterEditor
parameters={parameters}
onChange={onParametersChange}
/>
</div>
<div className="grid gap-2">
<Label>Fallback Route</Label>
<Input
value={fallbackRoute}
onChange={(e) => onFallbackRouteChange(e.target.value)}
placeholder="referral_default"
/>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div>
<Label>Approved Routes</Label>
<p className="text-xs text-muted-foreground">
Route keys the resolver can return, mapped to known transfer destinations.
</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={addRoute}>
<Plus className="h-4 w-4 mr-2" />
Add Route
</Button>
</div>
<div className="space-y-3">
{approvedRoutes.map((route, index) => (
<div key={index} className="grid gap-3 rounded-md border p-3">
<div className="grid gap-3 md:grid-cols-[1fr_1fr_auto]">
<div className="grid gap-2">
<Label>Route Key</Label>
<Input
value={route.key}
onChange={(e) =>
updateRoute(index, { key: e.target.value })
}
placeholder="referral_tx"
/>
</div>
<div className="grid gap-2">
<Label>Destination</Label>
<Input
value={route.destination}
onChange={(e) =>
updateRoute(index, { destination: e.target.value })
}
placeholder="+1234567890"
/>
</div>
<div className="flex items-end">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeRoute(index)}
aria-label="Remove route"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<div className="grid gap-3 md:grid-cols-[1fr_160px]">
<div className="grid gap-2">
<Label>Message</Label>
<Input
value={route.message || ""}
onChange={(e) =>
updateRoute(index, { message: e.target.value })
}
placeholder="Ill transfer you now."
/>
</div>
<div className="grid gap-2">
<Label>Timeout</Label>
<Input
type="number"
value={route.timeout_seconds ?? 30}
min="5"
max="120"
onChange={(e) => {
const value = parseInt(e.target.value) || 30;
updateRoute(index, {
timeout_seconds: Math.min(Math.max(value, 5), 120),
});
}}
/>
</div>
</div>
</div>
))}
{approvedRoutes.length === 0 && (
<p className="text-xs text-muted-foreground">
No approved routes configured.
</p>
)}
</div>
</div>
</div>
)}
</div>
</CardContent>
</Card>
);

View file

@ -14,7 +14,6 @@ import type {
HttpApiToolDefinition,
RecordingResponseSchema,
ToolResponse,
TransferCallConfig as APITransferCallConfig,
UpdateToolRequest,
} from "@/client/types.gen";
import {
@ -46,11 +45,14 @@ import { useAuth } from "@/lib/auth";
import {
createMcpDefinition,
DEFAULT_END_CALL_REASON_DESCRIPTION,
type ExtendedTransferCallConfig,
type EndCallMessageType,
getCategoryConfig,
getToolTypeLabel,
MCP_URL_PATTERN,
renderToolIcon,
type TransferApprovedRouteRow,
type TransferResolverPolicy,
type ToolCategory,
} from "../config";
import { BuiltinToolConfig, EndCallToolConfig, HttpApiToolConfig, TransferCallToolConfig } from "./components";
@ -67,6 +69,18 @@ function normalizeParameterType(value: string | null | undefined): ParameterType
}
}
function approvedRoutesToRows(
routes: ExtendedTransferCallConfig["approved_routes"] | undefined | null,
): TransferApprovedRouteRow[] {
if (!routes) return [];
return Object.entries(routes).map(([key, route]) => ({
key,
destination: route.destination || "",
message: route.message || "",
timeout_seconds: route.timeout_seconds ?? 30,
}));
}
export default function ToolDetailPage() {
const { toolUuid } = useParams<{ toolUuid: string }>();
const { user, getAccessToken, redirectToLogin, loading } = useAuth();
@ -113,6 +127,16 @@ export default function ToolDetailPage() {
const [transferMessageType, setTransferMessageType] = useState<EndCallMessageType>("none");
const [transferTimeout, setTransferTimeout] = useState(30);
const [transferAudioRecordingId, setTransferAudioRecordingId] = useState("");
const [transferResolverEnabled, setTransferResolverEnabled] = useState(false);
const [transferResolverUrl, setTransferResolverUrl] = useState("");
const [transferResolverCredentialUuid, setTransferResolverCredentialUuid] = useState("");
const [transferResolverTimeoutMs, setTransferResolverTimeoutMs] = useState(3000);
const [transferResolverWaitMessage, setTransferResolverWaitMessage] = useState("");
const [transferResolverPolicy, setTransferResolverPolicy] =
useState<TransferResolverPolicy>("approved_routes_only");
const [transferApprovedRoutes, setTransferApprovedRoutes] = useState<TransferApprovedRouteRow[]>([]);
const [transferFallbackRoute, setTransferFallbackRoute] = useState("");
const [transferParameters, setTransferParameters] = useState<ToolParameter[]>([]);
// HTTP API form state - custom message type
const [customMessageType, setCustomMessageType] = useState<'text' | 'audio'>('text');
@ -182,19 +206,44 @@ export default function ToolDetailPage() {
}
} else if (tool.category === "transfer_call") {
// Populate transfer call specific fields
const config = tool.definition?.config as APITransferCallConfig | undefined;
const config = tool.definition?.config as ExtendedTransferCallConfig | undefined;
if (config) {
setTransferDestination(config.destination || "");
setTransferMessageType(config.messageType || "none");
setCustomMessage(config.customMessage || "");
setTransferAudioRecordingId(config.audioRecordingId || "");
setTransferTimeout(config.timeout ?? 30);
setTransferResolverEnabled(Boolean(config.resolver));
setTransferResolverUrl(config.resolver?.url || "");
setTransferResolverCredentialUuid(config.resolver?.credential_uuid || "");
setTransferResolverTimeoutMs(config.resolver?.timeout_ms ?? 3000);
setTransferResolverWaitMessage(config.resolver?.wait_message || "");
setTransferResolverPolicy(config.resolver?.policy || "approved_routes_only");
setTransferApprovedRoutes(approvedRoutesToRows(config.approved_routes));
setTransferFallbackRoute(config.fallback_route || "");
setTransferParameters(
(config.parameters || []).map((p) => ({
name: p.name || "",
type: normalizeParameterType(p.type),
description: p.description || "",
required: p.required ?? true,
})),
);
} else {
setTransferDestination("");
setTransferMessageType("none");
setCustomMessage("");
setTransferAudioRecordingId("");
setTransferTimeout(30);
setTransferResolverEnabled(false);
setTransferResolverUrl("");
setTransferResolverCredentialUuid("");
setTransferResolverTimeoutMs(3000);
setTransferResolverWaitMessage("");
setTransferResolverPolicy("approved_routes_only");
setTransferApprovedRoutes([]);
setTransferFallbackRoute("");
setTransferParameters([]);
}
} else if (tool.category === "mcp") {
// Populate MCP specific fields
@ -296,10 +345,60 @@ export default function ToolDetailPage() {
if (tool.category === "calculator") {
// No validation needed for built-in tools
} else if (tool.category === "transfer_call") {
if (!normalizedTransferDestination) {
if (!transferResolverEnabled && !normalizedTransferDestination) {
setError("Please enter a transfer destination");
return;
}
if (transferResolverEnabled) {
const resolverUrlValidation = validateUrl(transferResolverUrl);
if (!resolverUrlValidation.valid) {
setError(resolverUrlValidation.error || "Invalid resolver URL");
return;
}
if (
transferResolverPolicy === "approved_routes_or_static_fallback" &&
!normalizedTransferDestination
) {
setError("Please enter a transfer destination for static fallback");
return;
}
const routeKeys = transferApprovedRoutes
.map((route) => route.key.trim())
.filter(Boolean);
const invalidRoutes = transferApprovedRoutes.filter(
(route) => !route.key.trim() || !route.destination.trim()
);
if (invalidRoutes.length > 0) {
setError("All approved routes must have a route key and destination");
return;
}
if (new Set(routeKeys).size !== routeKeys.length) {
setError("Approved route keys must be unique");
return;
}
if (
transferFallbackRoute.trim() &&
!routeKeys.includes(transferFallbackRoute.trim())
) {
setError("Fallback route must match an approved route key");
return;
}
const invalidTransferParams = transferParameters.filter(
(p) => !p.name.trim() || !p.description.trim()
);
if (invalidTransferParams.length > 0) {
setError("All resolver arguments must have a name and description");
return;
}
const transferParamNames = transferParameters
.map((p) => p.name.trim())
.filter(Boolean);
if (new Set(transferParamNames).size !== transferParamNames.length) {
setError("Resolver argument names must be unique");
return;
}
}
} else if (tool.category === "mcp") {
// Validate MCP server URL (must be http(s))
if (!mcpUrl.trim()) {
@ -370,6 +469,57 @@ export default function ToolDetailPage() {
},
};
} else if (tool.category === "transfer_call") {
const approvedRoutesObject = transferApprovedRoutes.reduce(
(acc, route) => {
const key = route.key.trim();
if (!key) return acc;
acc[key] = {
destination: route.destination.trim(),
message: route.message?.trim() || undefined,
timeout_seconds: route.timeout_seconds || undefined,
};
return acc;
},
{} as Record<string, {
destination: string;
message?: string;
timeout_seconds?: number;
}>,
);
const transferConfig: ExtendedTransferCallConfig = {
destination: normalizedTransferDestination,
messageType: transferMessageType,
customMessage: transferMessageType === "custom" ? customMessage : undefined,
audioRecordingId: transferMessageType === "audio" ? transferAudioRecordingId || undefined : undefined,
timeout: transferTimeout,
resolver: transferResolverEnabled
? {
type: "http",
url: transferResolverUrl.trim(),
credential_uuid: transferResolverCredentialUuid || undefined,
timeout_ms: transferResolverTimeoutMs,
wait_message: transferResolverWaitMessage.trim() || undefined,
policy: transferResolverPolicy,
}
: undefined,
approved_routes:
transferResolverEnabled && Object.keys(approvedRoutesObject).length > 0
? approvedRoutesObject
: undefined,
fallback_route:
transferResolverEnabled && transferFallbackRoute.trim()
? transferFallbackRoute.trim()
: undefined,
parameters:
transferResolverEnabled && transferParameters.length > 0
? transferParameters.map((p) => ({
name: p.name.trim(),
type: p.type,
description: p.description.trim(),
required: p.required,
}))
: undefined,
};
// Build transfer call request body
requestBody = {
name,
@ -377,14 +527,8 @@ export default function ToolDetailPage() {
definition: {
schema_version: 1,
type: "transfer_call",
config: {
destination: normalizedTransferDestination,
messageType: transferMessageType,
customMessage: transferMessageType === "custom" ? customMessage : undefined,
audioRecordingId: transferMessageType === "audio" ? transferAudioRecordingId || undefined : undefined,
timeout: transferTimeout,
},
},
config: transferConfig,
} as UpdateToolRequest["definition"],
};
} else if (tool.category === "mcp") {
requestBody = {
@ -658,6 +802,24 @@ const data = await response.json();`;
recordings={recordings}
timeout={transferTimeout}
onTimeoutChange={setTransferTimeout}
resolverEnabled={transferResolverEnabled}
onResolverEnabledChange={setTransferResolverEnabled}
resolverUrl={transferResolverUrl}
onResolverUrlChange={setTransferResolverUrl}
resolverCredentialUuid={transferResolverCredentialUuid}
onResolverCredentialUuidChange={setTransferResolverCredentialUuid}
resolverTimeoutMs={transferResolverTimeoutMs}
onResolverTimeoutMsChange={setTransferResolverTimeoutMs}
resolverWaitMessage={transferResolverWaitMessage}
onResolverWaitMessageChange={setTransferResolverWaitMessage}
resolverPolicy={transferResolverPolicy}
onResolverPolicyChange={setTransferResolverPolicy}
approvedRoutes={transferApprovedRoutes}
onApprovedRoutesChange={setTransferApprovedRoutes}
fallbackRoute={transferFallbackRoute}
onFallbackRouteChange={setTransferFallbackRoute}
parameters={transferParameters}
onParametersChange={setTransferParameters}
/>
) : isMcpTool ? (
<Card>

View file

@ -16,6 +16,35 @@ import type {
export type ToolCategory = "http_api" | "end_call" | "transfer_call" | "calculator" | "native" | "integration" | "mcp";
export type EndCallMessageType = "none" | "custom" | "audio";
export type TransferResolverPolicy =
| "approved_routes_only"
| "approved_routes_or_static_fallback"
| "allow_raw_destination";
export interface TransferResolverConfig {
type: "http";
url: string;
credential_uuid?: string | null;
timeout_ms: number;
wait_message?: string | null;
policy: TransferResolverPolicy;
}
export interface TransferApprovedRoute {
destination: string;
message?: string | null;
timeout_seconds?: number | null;
}
export interface TransferApprovedRouteRow extends TransferApprovedRoute {
key: string;
}
export interface ExtendedTransferCallConfig extends TransferCallConfig {
resolver?: TransferResolverConfig | null;
approved_routes?: Record<string, TransferApprovedRoute> | null;
fallback_route?: string | null;
}
export interface ToolCategoryConfig {
value: ToolCategory;

File diff suppressed because one or more lines are too long

View file

@ -2988,6 +2988,50 @@ export type HttpApiToolDefinition = {
config: HttpApiConfig;
};
/**
* HttpTransferResolverConfig
*
* HTTP endpoint used to resolve transfer destination at call time.
*/
export type HttpTransferResolverConfig = {
/**
* Type
*
* Resolver type.
*/
type?: 'http';
/**
* Url
*
* HTTP or HTTPS endpoint for transfer resolution.
*/
url: string;
/**
* Credential Uuid
*
* Reference to an external credential for resolver authentication.
*/
credential_uuid?: string | null;
/**
* Timeout Ms
*
* Resolver request timeout in milliseconds.
*/
timeout_ms?: number;
/**
* Wait Message
*
* Optional short message played while Dograh resolves routing.
*/
wait_message?: string | null;
/**
* Policy
*
* Controls what resolver responses are allowed to select.
*/
policy?: 'approved_routes_only' | 'approved_routes_or_static_fallback' | 'allow_raw_destination';
};
/**
* Hugging Face
*
@ -5791,6 +5835,40 @@ export type ToolResponse = {
created_by?: CreatedByResponse | null;
};
/**
* TransferApprovedRoute
*
* A pre-approved destination the transfer resolver may select.
*/
export type TransferApprovedRoute = {
/**
* Destination
*
* Phone number, SIP endpoint, or template for this route.
*/
destination: string;
/**
* Message
*
* Optional message to play before transferring to this route.
*/
message?: string | null;
/**
* Timeout Seconds
*
* Optional route-specific transfer answer timeout.
*/
timeout_seconds?: number | null;
/**
* Metadata
*
* Optional non-secret route metadata for logs and resolver context.
*/
metadata?: {
[key: string]: unknown;
} | null;
};
/**
* TransferCallConfig
*
@ -5827,6 +5905,30 @@ export type TransferCallConfig = {
* Maximum seconds to wait for the destination to answer.
*/
timeout?: number;
/**
* Parameters
*
* Parameters the model may provide when calling this transfer tool, for example state, department, or transfer reason.
*/
parameters?: Array<ToolParameter> | null;
/**
* Optional resolver that determines transfer routing at call time.
*/
resolver?: HttpTransferResolverConfig | null;
/**
* Approved Routes
*
* Approved route keys that a resolver may select.
*/
approved_routes?: {
[key: string]: TransferApprovedRoute;
} | null;
/**
* Fallback Route
*
* Approved route key to use when resolver resolution fails.
*/
fallback_route?: string | null;
};
/**