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.

When to Use Manual Tools
Manual Tools are ideal when you need:
| Scenario | Why Manual Tools |
|---|---|
| Human approval | Require human confirmation before critical actions |
| Custom UI flows | Collect data through your own interface (payment forms, file uploads) |
| External system queries | Query your own databases or internal APIs |
| Complex business logic | Execute multi-step processes in your backend |
| Sensitive operations | Keep sensitive logic entirely in your infrastructure |
Manual vs. Other Tool Types
| Aspect | API Integration / RAG / MCP | Manual Tools |
|---|---|---|
| Execution | SipPulse executes automatically | Your application executes |
| Flow | Agent continues immediately | Agent pauses and waits |
| Control | Configured in SipPulse | Full control in your code |
| Use case | Standard integrations | Custom/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.
| Field | Description |
|---|---|
| Name | Function name the agent will call (e.g., request_approval). Must match ^[a-zA-Z0-9_-]{1,64}$ |
| Description | Detailed explanation of when and how the agent should use this tool |
| Parameters | JSON 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:
- What the tool does — Clear explanation of functionality
- When to use it — Specific scenarios and triggers
- When NOT to use it — Avoid confusion with similar tools
- What it returns — Expected output format
- Limitations — What the tool cannot do
Aim for at least 3-4 sentences per tool description.
Good vs. Bad Descriptions
Processes refunds for orders.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:
{
"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_idnotoid) - Include examples in descriptions (
e.g., ORD-12345) - Use
enumfor constrained values to reduce errors - Add
additionalProperties: falsefor stricter validation - Mark truly required fields in
requiredarray
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:
{
"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:
| Field | Description |
|---|---|
finish_reason: "tool_use" | Indicates a tool was called |
tool_calls[].id | Critical: You need this ID to send the result back |
tool_calls[].name | The tool that was called |
tool_calls[].input | Object containing the parameters |
Sending Results Back
After processing the tool call, send the result as a new message:
{
"role": "user",
"content": [{
"type": "tool_result",
"tool_call_id": "toolu_01A09q90qw90lq917835",
"content": "{\"approved\": true, \"approved_by\": \"manager@company.com\"}"
}]
}Important Rules:
- The
tool_call_idmust exactly match theidfromtool_calls - The
contentcan 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:
{
"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):
// ❌ 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:
{
"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:
- Incorporates the error into its response to the user
- May retry with corrected parameters (typically 2-3 attempts)
- Eventually apologizes and explains the issue
Error message guidelines:
ENOTFOUND at db.orders.findById - connection refusedI 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)
{
"type": "tool_result",
"tool_call_id": "toolu_xxx",
"content": "The order status is: Shipped. Expected delivery: Jan 20."
}Structured JSON
{
"type": "tool_result",
"tool_call_id": "toolu_xxx",
"content": "{\"status\": \"shipped\", \"tracking\": \"1Z999AA10123456784\", \"eta\": \"2025-01-20\"}"
}Images (Base64)
{
"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:
{
"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 resultThis 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
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);
}
}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
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);
}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
| Practice | Why It Matters |
|---|---|
| Write detailed descriptions | Most important factor for tool performance |
| Use enums for constrained values | Reduces parameter errors |
| Return all results in one message | Enables parallel tool calls |
Use is_error: true for failures | Agent handles errors gracefully |
| Return user-friendly error messages | Better user experience |
| Validate inputs before execution | Security and data integrity |
| Log tool calls for debugging | Easier troubleshooting |
| Implement timeouts | Prevent 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
idfrom thetool_callsarray - 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
contentfield is a valid string or JSON - Check that
roleis exactly"user"withtool_resultcontent 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_resultmessage - 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
Related Documentation
- Tools Overview — Understanding agent tools
- API Integration — Auto-executed external API calls
- Deploy via API — API deployment and integration
