Skip to content

Manual Tools

Manual Tools are custom functions that pause agent execution and delegate processing to your application. Unlike other tool types that execute automatically, manual tools stop the conversation flow and wait for your system to provide the result.

Manual Tools configuration

When to Use Manual Tools

Manual Tools are ideal when you need:

ScenarioWhy Manual Tools
Human approvalRequire human confirmation before critical actions
Custom UI flowsCollect data through your own interface (payment forms, file uploads)
External system queriesQuery your own databases or internal APIs
Complex business logicExecute multi-step processes in your backend
Sensitive operationsKeep sensitive logic entirely in your infrastructure

Manual vs. Other Tool Types

AspectAPI Integration / RAG / MCPManual Tools
ExecutionSipPulse executes automaticallyYour application executes
FlowAgent continues immediatelyAgent pauses and waits
ControlConfigured in SipPulseFull control in your code
Use caseStandard integrationsCustom/sensitive operations

When to Choose Manual Tools

Use Manual Tools when you need the agent to stop and wait for external input. This is essential for human-in-the-loop workflows, custom UI interactions, or when sensitive operations must stay in your infrastructure.


How Manual Tools Work

The key difference from other tools is that the agent workflow stops when a manual tool is called. Your application must capture this, process the request, and send the result back.

1. User sends message
   "I need approval to process a $500 refund"

2. Agent decides to call manual tool
   Tool: request_approval
   Arguments: { action: "refund", amount: 500 }

3. Agent workflow STOPS (returns END)
   Thread status becomes "pending"

4. API returns response with tool_calls
   finish_reason: "tool_use"
   tool_calls: [{ id: "call_xyz", name: "...", input: {...} }]

5. YOUR APPLICATION captures this response
   Processes the tool call (show UI, query DB, etc.)

6. YOUR APPLICATION sends result back
   POST message with role: "tool"

7. Agent resumes with the result
   "The refund has been approved by the manager."

Critical Concept

Manual tools do NOT use webhooks. SipPulse does NOT call your server. Instead, your application polls or receives the API response and handles the tool call externally, then sends the result back via the messages API.


Creating a Manual Tool

Step 1: Configure in SipPulse

In the Agent configuration, add a new tool with type Manual.

FieldDescription
NameFunction name the agent will call (e.g., request_approval). Must match ^[a-zA-Z0-9_-]{1,64}$
DescriptionDetailed explanation of when and how the agent should use this tool
ParametersJSON Schema defining the expected input

Step 2: Write Effective Descriptions

Most Important Factor

Tool descriptions are the most important factor in tool performance. Poor descriptions lead to wrong tool selection, missing parameters, and unexpected behavior.

Your description should include:

  1. What the tool does — Clear explanation of functionality
  2. When to use it — Specific scenarios and triggers
  3. When NOT to use it — Avoid confusion with similar tools
  4. What it returns — Expected output format
  5. Limitations — What the tool cannot do

Aim for at least 3-4 sentences per tool description.

Good vs. Bad Descriptions

text
Processes refunds for orders.
text
Processes a refund for a customer order. The order_id must be a valid
order number from our system. Use this tool when a customer explicitly
requests a refund and has provided their order number. Before calling,
verify the order exists. Returns the refund ID and estimated completion
time. Does not handle exchanges or store credit — use the appropriate
tools for those scenarios. Refunds are limited to the 30-day return window.

Step 3: Define the Parameters Schema

Use JSON Schema to define what arguments the tool accepts. Follow these best practices:

json
{
  "type": "object",
  "properties": {
    "order_id": {
      "type": "string",
      "description": "The order number to refund (e.g., ORD-12345)"
    },
    "reason": {
      "type": "string",
      "enum": ["damaged", "wrong_item", "not_as_described", "changed_mind", "other"],
      "description": "The reason for the refund request"
    },
    "amount": {
      "type": "number",
      "description": "Partial refund amount in USD. Omit for full refund."
    }
  },
  "required": ["order_id", "reason"],
  "additionalProperties": false
}

Schema Best Practices:

  • Use clear, descriptive property names (order_id not oid)
  • Include examples in descriptions (e.g., ORD-12345)
  • Use enum for constrained values to reduce errors
  • Add additionalProperties: false for stricter validation
  • Mark truly required fields in required array

Integrating with Your Application

Understanding the API Response

When the agent calls a manual tool, the API response contains tool_calls instead of regular content:

json
{
  "id": "msg_abc123",
  "thread_id": "thread_xyz789",
  "choices": [{
    "message": {
      "role": "assistant",
      "content": null,
      "tool_calls": [{
        "id": "toolu_01A09q90qw90lq917835",
        "type": "function",
        "name": "request_approval",
        "input": {
          "action": "refund",
          "amount": 500.00,
          "reason": "Customer request"
        }
      }]
    },
    "finish_reason": "tool_use"
  }]
}

Key fields to check:

FieldDescription
finish_reason: "tool_use"Indicates a tool was called
tool_calls[].idCritical: You need this ID to send the result back
tool_calls[].nameThe tool that was called
tool_calls[].inputObject containing the parameters

Sending Results Back

After processing the tool call, send the result as a new message:

json
{
  "role": "user",
  "content": [{
    "type": "tool_result",
    "tool_call_id": "toolu_01A09q90qw90lq917835",
    "content": "{\"approved\": true, \"approved_by\": \"manager@company.com\"}"
  }]
}

Important Rules:

  • The tool_call_id must exactly match the id from tool_calls
  • The content can be a string, JSON string, or array of content blocks
  • After sending this message, the agent will resume execution

Handling Parallel Tool Calls

The agent may call multiple tools simultaneously in a single response. This is common when:

  • User asks for information from different sources
  • Multiple independent operations are needed
  • Efficiency gains are possible

Critical Rule

ALL tool results must be sent in a SINGLE message. Sending them separately "teaches" the model to avoid parallel calls in the future.

Correct — All results in one message:

json
{
  "role": "user",
  "content": [
    {
      "type": "tool_result",
      "tool_call_id": "toolu_01_weather_sf",
      "content": "San Francisco: 68°F, partly cloudy"
    },
    {
      "type": "tool_result",
      "tool_call_id": "toolu_02_weather_nyc",
      "content": "New York: 45°F, clear skies"
    }
  ]
}

Wrong — Separate messages (causes issues):

json
// ❌ Message 1
{ "role": "user", "content": [{ "type": "tool_result", "tool_call_id": "toolu_01...", ... }] }

// ❌ Message 2 - This breaks the pattern!
{ "role": "user", "content": [{ "type": "tool_result", "tool_call_id": "toolu_02...", ... }] }

Handling Errors

When tool execution fails, return the error with is_error: true:

json
{
  "type": "tool_result",
  "tool_call_id": "toolu_01A09q90qw90lq917835",
  "content": "Order #12345 was not found or is not eligible for refund.",
  "is_error": true
}

How the agent handles errors:

  1. Incorporates the error into its response to the user
  2. May retry with corrected parameters (typically 2-3 attempts)
  3. Eventually apologizes and explains the issue

Error message guidelines:

text
ENOTFOUND at db.orders.findById - connection refused
text
I couldn't find an order with that number. Could you double-check it?

Content Types for Results

Tool results can return different content types:

Text (Most Common)

json
{
  "type": "tool_result",
  "tool_call_id": "toolu_xxx",
  "content": "The order status is: Shipped. Expected delivery: Jan 20."
}

Structured JSON

json
{
  "type": "tool_result",
  "tool_call_id": "toolu_xxx",
  "content": "{\"status\": \"shipped\", \"tracking\": \"1Z999AA10123456784\", \"eta\": \"2025-01-20\"}"
}

Images (Base64)

json
{
  "type": "tool_result",
  "tool_call_id": "toolu_xxx",
  "content": [
    { "type": "text", "text": "Here is the product image:" },
    {
      "type": "image",
      "source": {
        "type": "base64",
        "media_type": "image/jpeg",
        "data": "/9j/4AAQSkZJRg..."
      }
    }
  ]
}

Empty Result

For tools that perform actions without returning data:

json
{
  "type": "tool_result",
  "tool_call_id": "toolu_xxx"
}

Security Considerations

The Model Suggests — You Execute

The LLM suggests tool calls but doesn't execute them. This pattern is intentional and safe:

1. Model proposes: { name: "delete_account", input: { user_id: "123" } }
2. YOUR CODE validates the request
3. YOUR CODE executes (or rejects)
4. YOUR CODE returns the result

This architecture means you have full control over what actually happens.

Validation Checklist

Before executing any tool:

  • Validate all input parameters — Don't trust the model's input blindly
  • Verify user authorization — Ensure the user can perform this action
  • Confirm destructive operations — Consider requiring human approval
  • Implement rate limiting — Prevent abuse from repeated calls
  • Add circuit breakers — Stop if too many errors occur

Example: Validating Before Execution

typescript
async function handleToolCall(toolCall: ToolCall, userId: string) {
  const { name, input } = toolCall;

  // Validate input format
  if (name === 'process_refund') {
    if (!isValidOrderId(input.order_id)) {
      return { error: 'Invalid order ID format', is_error: true };
    }

    // Verify user authorization
    const order = await getOrder(input.order_id);
    if (order.user_id !== userId) {
      return { error: 'You can only refund your own orders', is_error: true };
    }

    // Check business rules
    if (order.created_at < thirtyDaysAgo()) {
      return { error: 'Order is outside the 30-day refund window', is_error: true };
    }

    // Execute the actual refund
    return await processRefund(order, input.amount);
  }
}
python
async def handle_tool_call(tool_call: dict, user_id: str) -> dict:
    name = tool_call["name"]
    input_data = tool_call["input"]

    if name == "process_refund":
        # Validate input format
        if not is_valid_order_id(input_data.get("order_id")):
            return {"error": "Invalid order ID format", "is_error": True}

        # Verify user authorization
        order = await get_order(input_data["order_id"])
        if order.user_id != user_id:
            return {"error": "You can only refund your own orders", "is_error": True}

        # Check business rules
        if order.created_at < thirty_days_ago():
            return {"error": "Order is outside the 30-day refund window", "is_error": True}

        # Execute the actual refund
        return await process_refund(order, input_data.get("amount"))

Complete Integration Example

typescript
import axios from 'axios';

const API_BASE = 'https://api.sippulse.ai';
const API_KEY = 'your_api_key';

interface ToolCall {
  id: string;
  name: string;
  input: Record<string, unknown>;
}

interface ToolResult {
  tool_call_id: string;
  content: string;
  is_error?: boolean;
}

// Send a message and check for tool calls
async function sendMessage(threadId: string, content: string) {
  const response = await axios.post(
    `${API_BASE}/threads/${threadId}/messages`,
    { role: 'user', content },
    { headers: { Authorization: `Bearer ${API_KEY}` } }
  );

  const choice = response.data.choices[0];

  if (choice.finish_reason === 'tool_use' && choice.message.tool_calls) {
    return { type: 'tool_calls', toolCalls: choice.message.tool_calls };
  }

  return { type: 'message', content: choice.message.content };
}

// Process tool calls and return results
async function handleToolCalls(toolCalls: ToolCall[]): Promise<ToolResult[]> {
  const results: ToolResult[] = [];

  for (const toolCall of toolCalls) {
    try {
      const result = await executeToolCall(toolCall);
      results.push({
        tool_call_id: toolCall.id,
        content: JSON.stringify(result)
      });
    } catch (error) {
      results.push({
        tool_call_id: toolCall.id,
        content: error.message,
        is_error: true
      });
    }
  }

  return results;
}

// Execute a single tool call
async function executeToolCall(toolCall: ToolCall): Promise<unknown> {
  switch (toolCall.name) {
    case 'request_approval':
      // Show approval UI, wait for human decision
      const approved = await showApprovalDialog(toolCall.input);
      return {
        approved,
        approved_by: approved ? getCurrentUser() : null,
        timestamp: new Date().toISOString()
      };

    case 'check_inventory':
      // Query internal inventory system
      return await queryInventorySystem(toolCall.input.product_id as string);

    default:
      throw new Error(`Unknown tool: ${toolCall.name}`);
  }
}

// Send all tool results back (MUST be in single message!)
async function sendToolResults(threadId: string, results: ToolResult[]) {
  const response = await axios.post(
    `${API_BASE}/threads/${threadId}/messages`,
    {
      role: 'user',
      content: results.map(r => ({
        type: 'tool_result',
        tool_call_id: r.tool_call_id,
        content: r.content,
        ...(r.is_error && { is_error: true })
      }))
    },
    { headers: { Authorization: `Bearer ${API_KEY}` } }
  );

  return response.data;
}

// Main conversation loop
async function conversationLoop(threadId: string, userMessage: string) {
  let result = await sendMessage(threadId, userMessage);

  // Keep processing until we get a final message (not tool calls)
  while (result.type === 'tool_calls') {
    // Process ALL tool calls
    const toolResults = await handleToolCalls(result.toolCalls);

    // Send ALL results in a SINGLE message
    const nextResponse = await sendToolResults(threadId, toolResults);
    const choice = nextResponse.choices[0];

    if (choice.finish_reason === 'tool_use' && choice.message.tool_calls) {
      result = { type: 'tool_calls', toolCalls: choice.message.tool_calls };
    } else {
      result = { type: 'message', content: choice.message.content };
    }
  }

  console.log('Agent:', result.content);
}
python
import requests
import json
from typing import Optional
from dataclasses import dataclass

API_BASE = "https://api.sippulse.ai"
API_KEY = "your_api_key"

@dataclass
class ToolResult:
    tool_call_id: str
    content: str
    is_error: bool = False

def send_message(thread_id: str, content: str) -> dict:
    """Send a message and return the response."""
    response = requests.post(
        f"{API_BASE}/threads/{thread_id}/messages",
        json={"role": "user", "content": content},
        headers={"Authorization": f"Bearer {API_KEY}"}
    )
    return response.json()

async def handle_tool_calls(tool_calls: list) -> list[ToolResult]:
    """Process all tool calls and return results."""
    results = []

    for tool_call in tool_calls:
        try:
            result = await execute_tool_call(tool_call)
            results.append(ToolResult(
                tool_call_id=tool_call["id"],
                content=json.dumps(result)
            ))
        except Exception as e:
            results.append(ToolResult(
                tool_call_id=tool_call["id"],
                content=str(e),
                is_error=True
            ))

    return results

async def execute_tool_call(tool_call: dict) -> dict:
    """Execute a single tool call."""
    name = tool_call["name"]
    input_data = tool_call["input"]

    if name == "request_approval":
        # Show approval UI, wait for human decision
        approved = await show_approval_dialog(input_data)
        return {
            "approved": approved,
            "approved_by": get_current_user() if approved else None,
            "timestamp": datetime.now().isoformat()
        }

    elif name == "check_inventory":
        # Query internal inventory system
        return await query_inventory_system(input_data["product_id"])

    else:
        raise ValueError(f"Unknown tool: {name}")

def send_tool_results(thread_id: str, results: list[ToolResult]) -> dict:
    """Send ALL tool results in a SINGLE message."""
    content = [
        {
            "type": "tool_result",
            "tool_call_id": r.tool_call_id,
            "content": r.content,
            **({"is_error": True} if r.is_error else {})
        }
        for r in results
    ]

    response = requests.post(
        f"{API_BASE}/threads/{thread_id}/messages",
        json={"role": "user", "content": content},
        headers={"Authorization": f"Bearer {API_KEY}"}
    )
    return response.json()

async def conversation_loop(thread_id: str, user_message: str):
    """Main conversation handler with tool call loop."""
    response = send_message(thread_id, user_message)
    choice = response["choices"][0]

    # Keep processing until we get a final message
    while choice.get("finish_reason") == "tool_use":
        tool_calls = choice["message"].get("tool_calls", [])

        # Process ALL tool calls
        results = await handle_tool_calls(tool_calls)

        # Send ALL results in a SINGLE message
        response = send_tool_results(thread_id, results)
        choice = response["choices"][0]

    print("Agent:", choice["message"]["content"])

Best Practices Summary

PracticeWhy It Matters
Write detailed descriptionsMost important factor for tool performance
Use enums for constrained valuesReduces parameter errors
Return all results in one messageEnables parallel tool calls
Use is_error: true for failuresAgent handles errors gracefully
Return user-friendly error messagesBetter user experience
Validate inputs before executionSecurity and data integrity
Log tool calls for debuggingEasier troubleshooting
Implement timeoutsPrevent hanging operations

Troubleshooting

Agent Doesn't Call the Tool

  • Check the description — Is it detailed enough? Does it explain when to use the tool?
  • Verify the tool is enabled — Ensure it's active in the agent's tool configuration
  • Test with explicit prompts — Try "Use the request_approval tool to approve this refund"

"Invalid tool_call_id" Error

  • Ensure you're using the exact id from the tool_calls array
  • Don't modify or generate your own IDs
  • For parallel calls, match each result to its corresponding ID

Agent Receives Empty or Wrong Result

  • Verify your content field is a valid string or JSON
  • Check that role is exactly "user" with tool_result content type
  • For JSON results, ensure proper encoding with JSON.stringify() / json.dumps()

Thread Stuck in "pending" Status

  • You haven't sent the tool result yet
  • Check your integration code is properly sending the tool_result message
  • Verify the API call succeeded (check for errors in response)

Parallel Tool Calls Not Working

  • Are you sending all results in a single message?
  • Separate messages "teach" the model to avoid parallel calls
  • Check the message format matches the examples above