feat: add test mode for API trigger

This commit is contained in:
Abhishek Kumar 2026-04-25 16:30:26 +05:30
parent f041e6030d
commit 4171ad7a54
13 changed files with 279 additions and 124 deletions

View file

@ -1,6 +0,0 @@
{
"effortLevel": "high",
"env": {
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1"
}
}

View file

@ -5,7 +5,9 @@ from api.mcp_server.instructions import DOGRAH_MCP_INSTRUCTIONS
mcp = FastMCP("dograh", instructions=DOGRAH_MCP_INSTRUCTIONS)
from api.mcp_server.tools import catalog as _catalog # noqa: E402, F401
from api.mcp_server.tools import get_workflow_code as _get_workflow_code # noqa: E402, F401
from api.mcp_server.tools import (
get_workflow_code as _get_workflow_code, # noqa: E402, F401
)
from api.mcp_server.tools import node_types as _node_types # noqa: E402, F401
from api.mcp_server.tools import save_workflow as _save_workflow # noqa: E402, F401
from api.mcp_server.tools import workflows as _workflows # noqa: E402, F401

View file

@ -53,27 +53,17 @@ def trigger_exists_in_workflow(workflow_definition: dict, trigger_path: str) ->
return False
@router.post("/{uuid}", response_model=TriggerCallResponse)
async def initiate_call(
async def _initiate_call(
uuid: str,
request: TriggerCallRequest,
x_api_key: str = Header(..., alias="X-API-Key"),
):
"""Initiate a phone call via API trigger.
x_api_key: str,
*,
use_draft: bool,
) -> TriggerCallResponse:
"""Shared core for production and test trigger endpoints.
This endpoint allows external systems (CRMs, automation tools, etc.) to
programmatically trigger outbound phone calls with custom context variables.
Args:
uuid: The unique trigger UUID
request: The call request with phone number and optional context
x_api_key: API key for authentication (passed in X-API-Key header)
Returns:
TriggerCallResponse with workflow run details
Raises:
HTTPException: Various error conditions (401, 403, 404, 400)
When ``use_draft`` is True the latest draft definition is executed;
otherwise the published (released) definition is used.
"""
# 1. Validate API key
api_key = await db_client.validate_api_key(x_api_key)
@ -98,14 +88,23 @@ async def initiate_call(
if not quota_result.has_quota:
raise HTTPException(status_code=402, detail=quota_result.error_message)
# 5. Get workflow and validate trigger exists in definition
# 5. Get workflow and resolve the definition (published vs draft)
workflow = await db_client.get_workflow_by_id(trigger.workflow_id)
if not workflow:
raise HTTPException(status_code=404, detail="Workflow not found")
workflow_definition = workflow.released_definition.workflow_json
if use_draft:
draft = await db_client.get_draft_version(trigger.workflow_id)
# Fall back to the published definition when no draft exists, so the
# test URL always runs *something* — typically the same agent the
# production URL would run.
workflow_definition = (
draft.workflow_json if draft else workflow.released_definition.workflow_json
)
else:
workflow_definition = workflow.released_definition.workflow_json
# Validate trigger node still exists in the workflow definition
# Validate trigger node still exists in the resolved definition
if not trigger_exists_in_workflow(workflow_definition, uuid):
raise HTTPException(
status_code=404,
@ -126,7 +125,8 @@ async def initiate_call(
workflow_run_mode = provider.PROVIDER_NAME
# 8. Create workflow run
workflow_run_name = f"WR-API-{random.randint(1000, 9999)}"
mode_label = "TEST" if use_draft else "API"
workflow_run_name = f"WR-{mode_label}-{random.randint(1000, 9999)}"
workflow_run = await db_client.create_workflow_run(
name=workflow_run_name,
workflow_id=trigger.workflow_id,
@ -135,13 +135,16 @@ async def initiate_call(
"provider": provider.PROVIDER_NAME,
"phone_number": request.phone_number,
"agent_uuid": uuid,
"trigger_mode": "test" if use_draft else "production",
**(request.initial_context or {}),
},
user_id=api_key.created_by,
use_draft=use_draft,
)
logger.info(
f"Created workflow run {workflow_run.id} for API trigger {uuid} "
f"(mode={'test' if use_draft else 'production'}) "
f"to phone number {request.phone_number}"
)
@ -183,3 +186,30 @@ async def initiate_call(
workflow_run_id=workflow_run.id,
workflow_run_name=workflow_run_name,
)
@router.post("/{uuid}", response_model=TriggerCallResponse)
async def initiate_call(
uuid: str,
request: TriggerCallRequest,
x_api_key: str = Header(..., alias="X-API-Key"),
):
"""Initiate a phone call against the published agent.
Executes the workflow's currently released definition.
"""
return await _initiate_call(uuid, request, x_api_key, use_draft=False)
@router.post("/test/{uuid}", response_model=TriggerCallResponse)
async def initiate_call_test(
uuid: str,
request: TriggerCallRequest,
x_api_key: str = Header(..., alias="X-API-Key"),
):
"""Initiate a phone call against the latest draft of the agent.
Useful for verifying changes before publishing. Falls back to the
published definition when no draft exists.
"""
return await _initiate_call(uuid, request, x_api_key, use_draft=True)

View file

@ -56,7 +56,10 @@ async def get_user(
# ------------------------------------------------------------------
try:
user_model, user_was_created = await db_client.get_or_create_user_by_provider_id(stack_user["id"])
(
user_model,
user_was_created,
) = await db_client.get_or_create_user_by_provider_id(stack_user["id"])
# Sync email from Stack Auth if available and not already set
stack_email = stack_user.get("primary_email_verified") and stack_user.get(

View file

@ -660,7 +660,7 @@ TTSConfig = Annotated[
###################################################### STT ########################################################################
DEEPGRAM_STT_MODELS = ["nova-3-general", "flux-general-en"]
DEEPGRAM_STT_MODELS = ["nova-3-general", "flux-general-en", "flux-general-multi"]
DEEPGRAM_LANGUAGES = [
"multi",
"ar",

View file

@ -13,11 +13,16 @@ from api.services.workflow.node_specs._base import (
SPEC = NodeSpec(
name="trigger",
display_name="API Trigger",
description="Public HTTP endpoint that launches the workflow.",
description=("Public HTTP endpoints that launch the workflow."),
llm_hint=(
"Exposes a public HTTP POST endpoint. External systems call the URL "
"(derived from the auto-generated `trigger_path`) to launch this "
"workflow. Requires an API key in the `X-API-Key` header."
"Exposes two public HTTP POST endpoints derived from the auto-generated "
"`trigger_path`:\n"
" • Production: `<backend>/api/v1/public/agent/<trigger_path>` — runs "
"the published agent. Use this from production systems.\n"
" • Test: `<backend>/api/v1/public/agent/test/<trigger_path>` — runs "
"the latest draft, useful for verifying changes before publishing. "
"Falls back to the published agent when no draft exists.\n"
"Both require an API key in the `X-API-Key` header."
),
category=NodeCategory.trigger,
icon="Webhook",
@ -44,7 +49,12 @@ SPEC = NodeSpec(
display_name="Trigger Path",
description=(
"Auto-generated UUID-style path segment that uniquely "
"identifies this trigger. Do not edit manually."
"identifies this trigger. Used in both URLs:\n"
" • Production: `/api/v1/public/agent/<trigger_path>` — "
"executes the published agent.\n"
" • Test: `/api/v1/public/agent/test/<trigger_path>` — "
"executes the latest draft.\n"
"Do not edit manually."
),
),
],

View file

@ -13,8 +13,6 @@ Covers:
from __future__ import annotations
from typing import Any
import pytest
from dograh_sdk import Workflow
from dograh_sdk._generated_models import NodeSpec

View file

@ -9,8 +9,6 @@ Covers:
from __future__ import annotations
from typing import Any
import pytest
from dograh_sdk import Workflow
from dograh_sdk._generated_models import NodeSpec

View file

@ -14,16 +14,26 @@ This is useful when you want to trigger calls from your own backend, a CRM, or w
## Finding your trigger URL
When you add an API Trigger node to your workflow, Dograh assigns it a unique UUID. You can find this UUID in the dashboard URL when viewing the agent, or in the trigger node settings.
Your trigger endpoint is:
When you add an API Trigger node to your workflow, Dograh assigns it a unique UUID. The trigger node exposes two URLs that share this UUID — one for the published agent and one for the latest draft. You can copy either URL from the trigger node's settings dialog.
```
POST https://your-dograh-instance/api/v1/public/agent/{uuid}
POST https://your-dograh-instance/api/v1/public/agent/{uuid} # Production
POST https://your-dograh-instance/api/v1/public/agent/test/{uuid} # Test
```
If you are using the hosted version, replace `your-dograh-instance` with `api.dograh.com`.
### Test vs production
| Mode | URL | Runs |
|------------|------------------------------------|---------------------------------------------------------------------------|
| Production | `/api/v1/public/agent/{uuid}` | The published version of the agent. |
| Test | `/api/v1/public/agent/test/{uuid}` | The latest draft. Falls back to the published version if no draft exists. |
Use the test URL while iterating on changes so production traffic continues to hit the published version. Once you publish your draft, both URLs run the same definition.
The request body, headers, and response shape are identical for both URLs.
## Making a request
Authenticate by passing your API key in the `X-API-Key` header. The request body requires a `phone_number` and accepts an optional `initial_context` object.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -2110,30 +2110,6 @@ export type MpsCreditsResponse = {
total_quota: number;
};
/**
* MigrationSpec
*
* Declared migration step (JSON-serializable view).
*
* The migrate callable is registered out-of-band via `register_migration()`
* and never serialized LLM and frontend consumers only see version
* metadata and warn on mismatch.
*/
export type MigrationSpec = {
/**
* From Version
*/
from_version: string;
/**
* To Version
*/
to_version: string;
/**
* Description
*/
description: string;
};
/**
* NodeCategory
*
@ -2206,10 +2182,6 @@ export type NodeSpec = {
* Examples
*/
examples?: Array<NodeExample>;
/**
* Migrations
*/
migrations?: Array<MigrationSpec>;
graph_constraints?: GraphConstraints | null;
};
@ -8875,6 +8847,46 @@ export type InitiateCallApiV1PublicAgentUuidPostResponses = {
export type InitiateCallApiV1PublicAgentUuidPostResponse = InitiateCallApiV1PublicAgentUuidPostResponses[keyof InitiateCallApiV1PublicAgentUuidPostResponses];
export type InitiateCallTestApiV1PublicAgentTestUuidPostData = {
body: TriggerCallRequest;
headers: {
/**
* X-Api-Key
*/
'X-API-Key': string;
};
path: {
/**
* Uuid
*/
uuid: string;
};
query?: never;
url: '/api/v1/public/agent/test/{uuid}';
};
export type InitiateCallTestApiV1PublicAgentTestUuidPostErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type InitiateCallTestApiV1PublicAgentTestUuidPostError = InitiateCallTestApiV1PublicAgentTestUuidPostErrors[keyof InitiateCallTestApiV1PublicAgentTestUuidPostErrors];
export type InitiateCallTestApiV1PublicAgentTestUuidPostResponses = {
/**
* Successful Response
*/
200: TriggerCallResponse;
};
export type InitiateCallTestApiV1PublicAgentTestUuidPostResponse = InitiateCallTestApiV1PublicAgentTestUuidPostResponses[keyof InitiateCallTestApiV1PublicAgentTestUuidPostResponses];
export type DownloadWorkflowArtifactApiV1PublicDownloadWorkflowTokenArtifactTypeGetData = {
body?: never;
path: {

View file

@ -11,7 +11,9 @@ import { NodeEditForm, useNodeSpecs } from "@/components/flow/renderer";
import { ToolBadges } from "@/components/flow/ToolBadges";
import { FlowNodeData } from "@/components/flow/types";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { NODE_DOCUMENTATION_URLS } from "@/constants/documentation";
import { cn } from "@/lib/utils";
import { NodeContent } from "./common/NodeContent";
import { NodeEditDialog } from "./common/NodeEditDialog";
@ -80,12 +82,22 @@ function seedValues(
return out;
}
function buildTriggerEndpoint(triggerPath: string | undefined): string {
if (!triggerPath) return "";
interface TriggerEndpoints {
production: string;
test: string;
}
function buildTriggerEndpoints(
triggerPath: string | undefined,
): TriggerEndpoints {
if (!triggerPath) return { production: "", test: "" };
const backendUrl =
process.env.NEXT_PUBLIC_BACKEND_URL ||
(typeof window !== "undefined" ? window.location.origin : "");
return `${backendUrl}/api/v1/public/agent/${triggerPath}`;
return {
production: `${backendUrl}/api/v1/public/agent/${triggerPath}`,
test: `${backendUrl}/api/v1/public/agent/test/${triggerPath}`,
};
}
// ─── Canvas preview dispatch ──────────────────────────────────────────────
@ -106,7 +118,7 @@ function CanvasPreview({
onStaleDocuments: (uuids: string[]) => void;
}) {
if (spec.name === "trigger") {
const endpoint = buildTriggerEndpoint(data.trigger_path);
const endpoint = buildTriggerEndpoints(data.trigger_path).production;
return (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">API Endpoint:</p>
@ -229,21 +241,105 @@ function StatusDot({ enabled }: { enabled: boolean }) {
);
}
// ─── Trigger curl example helper (rendered inside the dialog form) ───────
// ─── Trigger webhook URLs (test + production) — rendered inside the dialog
function TriggerCurlExample({ endpoint }: { endpoint: string }) {
const [copied, setCopied] = useState(false);
const curl = `curl -X POST "${endpoint}" \\
function buildCurl(endpoint: string): string {
return `curl -X POST "${endpoint}" \\
-H "X-API-Key: YOUR_API_KEY" \\
-H "Content-Type: application/json" \\
-d '{"phone_number": "+1234567890", "initial_context": {}}'`;
}
function ClickToCopy({
value,
children,
className,
title,
}: {
value: string;
children: React.ReactNode;
className?: string;
title?: string;
}) {
const [copied, setCopied] = useState(false);
const onCopy = async () => {
if (!value) return;
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<button
type="button"
onClick={onCopy}
title={title ?? "Click to copy"}
className={cn(
"group relative text-left transition-colors hover:bg-accent/60 cursor-pointer disabled:cursor-default",
className,
)}
disabled={!value}
>
{children}
<span
aria-hidden={!copied}
className={cn(
"pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 rounded bg-foreground/90 px-1.5 py-0.5 text-[10px] font-medium text-background shadow transition-opacity",
copied ? "opacity-100" : "opacity-0",
)}
>
Copied!
</span>
</button>
);
}
function UrlPanel({
endpoint,
helperText,
}: {
endpoint: string;
helperText: string;
}) {
const curl = endpoint ? buildCurl(endpoint) : "";
return (
<div className="grid gap-2 pt-2">
<div className="flex items-center gap-2">
<span className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded shrink-0">
POST
</span>
<ClickToCopy
value={endpoint}
title="Click to copy URL"
className="flex-1 bg-muted rounded px-2 py-1"
>
<code className="text-xs break-all">
{endpoint || "Generating..."}
</code>
</ClickToCopy>
</div>
<p className="text-xs text-muted-foreground">{helperText}</p>
<p className="text-sm font-medium pt-2">Example Request</p>
<ClickToCopy
value={curl}
title="Click to copy curl"
className="block w-full bg-muted rounded"
>
<pre className="text-xs px-3 py-2 overflow-x-auto whitespace-pre-wrap">
{curl || "Generating..."}
</pre>
</ClickToCopy>
</div>
);
}
function TriggerWebhookUrls({ endpoints }: { endpoints: TriggerEndpoints }) {
return (
<div className="grid gap-2">
<p className="text-sm font-medium">API Endpoint</p>
<p className="text-sm font-medium">Webhook URLs</p>
<p className="text-xs text-muted-foreground">
Use this endpoint to trigger calls via API. Requires an API key in
the X-API-Key header.{" "}
Test mode runs the latest draft so you can verify changes before
publishing. Production runs the published agent. Both require an
API key in the X-API-Key header.{" "}
<Link
href="/api-keys"
target="_blank"
@ -252,27 +348,24 @@ function TriggerCurlExample({ endpoint }: { endpoint: string }) {
Get your API key
</Link>
</p>
<code className="text-xs break-all bg-muted px-2 py-1 rounded">
{endpoint || "Generating..."}
</code>
<p className="text-sm font-medium pt-2">Example Request</p>
<div className="relative">
<pre className="text-xs bg-muted px-3 py-2 rounded overflow-x-auto whitespace-pre-wrap">
{curl}
</pre>
<Button
variant="outline"
size="icon"
className="absolute top-2 right-2"
onClick={async () => {
await navigator.clipboard.writeText(curl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}}
>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
<Tabs defaultValue="test" className="w-full">
<TabsList>
<TabsTrigger value="test">Test URL</TabsTrigger>
<TabsTrigger value="production">Production URL</TabsTrigger>
</TabsList>
<TabsContent value="test">
<UrlPanel
endpoint={endpoints.test}
helperText="Runs the latest draft, falling back to the published agent when no draft exists."
/>
</TabsContent>
<TabsContent value="production">
<UrlPanel
endpoint={endpoints.production}
helperText="Runs the published agent."
/>
</TabsContent>
</Tabs>
</div>
);
}
@ -318,7 +411,7 @@ export const GenericNode = memo(({ data, selected, id, type }: GenericNodeProps)
// ── Trigger auto-UUID + canvas copy state ──────────────────────────
const [triggerCopied, setTriggerCopied] = useState(false);
const handleCopyTrigger = useCallback(async () => {
const endpoint = buildTriggerEndpoint(data.trigger_path);
const endpoint = buildTriggerEndpoints(data.trigger_path).production;
if (!endpoint) return;
await navigator.clipboard.writeText(endpoint);
setTriggerCopied(true);
@ -472,8 +565,8 @@ export const GenericNode = memo(({ data, selected, id, type }: GenericNodeProps)
}}
/>
{type === "trigger" && (
<TriggerCurlExample
endpoint={buildTriggerEndpoint(data.trigger_path)}
<TriggerWebhookUrls
endpoints={buildTriggerEndpoints(data.trigger_path)}
/>
)}
</div>