Designing a Voice AI System Prompt with Claude Tool Use in Laravel (Part 3 of 4)
The Tool Use Concept (For Beginners)
When you ask a voice assistant "how much to go to the airport?", the AI can't just make up a price. It needs to:
- Recognise this as a fare estimate request.
- Call a function (
get_fare_estimate) with the destination. - Get the real fare data back.
- Formulate a spoken response using that data.
This is what Claude's tool use API does. You define "tools" as JSON schemas — essentially function descriptions — and Claude decides when to call them. Your code executes the actual function, sends the result back to Claude, and Claude gives a final response the user hears.
The LanguageModelService
Place at Modules/Assistant/app/Services/LanguageModelService.php.
<?php
namespace Modules\Assistant\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Modules\Assistant\Enums\ModuleContextEnum;
class LanguageModelService
{
private string $apiKey;
private string $baseUrl;
private string $model;
private int $maxTokens;
private float $temperature;
private string $apiVersion;
public function __construct()
{
$this->apiKey = (string) (config('assistant.anthropic.api_key') ?? '');
$this->baseUrl = (string) (config('assistant.anthropic.base_url') ?? '');
$this->model = (string) (config('assistant.anthropic.model') ?? '');
$this->maxTokens = (int) (config('assistant.anthropic.max_tokens') ?? 1024);
$this->temperature = (float) (config('assistant.anthropic.temperature') ?? 0.3);
$this->apiVersion = (string) (config('assistant.anthropic.api_version') ?? '');
}
/**
* Send a conversation turn to Claude and get a response (or a tool call).
*
* @param array $messages Conversation history [{role, content}]
* @param array $userContext User info + active records for system prompt
* @param string $language 'ar'|'en'|'mixed'
* @param ModuleContextEnum $moduleContext Which tools to expose
* @return array{
* response_text: string,
* tool_name: string|null,
* tool_input: array|null,
* tool_use_id: string|null,
* raw_content: array,
* input_tokens: int,
* output_tokens: int,
* latency_ms: int
* }
*/
public function complete(
array $messages,
array $userContext,
string $language,
ModuleContextEnum $moduleContext
): array {
$systemPrompt = $this->composeSystemPrompt($userContext, $language, $moduleContext);
$tools = $this->resolveAvailableTools($moduleContext);
$startMs = (int) (microtime(true) * 1000);
$payload = [
'model' => $this->model,
'max_tokens' => $this->maxTokens,
'temperature' => $this->temperature,
'system' => $systemPrompt,
'messages' => $messages,
];
if (! empty($tools)) {
$payload['tools'] = $tools;
// Disable parallel tool use — prevents partial tool_result errors
// when Claude tries to call multiple tools simultaneously.
$payload['tool_choice'] = ['type' => 'auto', 'disable_parallel_tool_use' => true];
}
$response = Http::withHeaders([
'x-api-key' => $this->apiKey,
'anthropic-version' => $this->apiVersion,
'content-type' => 'application/json',
])
->timeout(60)
->post($this->baseUrl . '/messages', $payload);
$latencyMs = (int) (microtime(true) * 1000) - $startMs;
if ($response->failed()) {
Log::error('Claude API error', [
'status' => $response->status(),
'body' => $response->body(),
]);
throw new \RuntimeException('LLM service error: ' . $response->status());
}
$data = $response->json();
$inputTokens = $data['usage']['input_tokens'] ?? 0;
$outputTokens = $data['usage']['output_tokens'] ?? 0;
// Normalize content: PHP's json_decode turns {} into [], which re-encodes
// as a JSON array. Cast empty tool_use.input to object so it round-trips as {}.
$content = array_map(function (array $block): array {
if (($block['type'] ?? '') === 'tool_use' && empty($block['input'])) {
$block['input'] = (object) [];
}
return $block;
}, $data['content'] ?? []);
$toolUse = collect($content)->firstWhere('type', 'tool_use');
$textBlock = collect($content)->firstWhere('type', 'text');
return [
'response_text' => $textBlock['text'] ?? '',
'tool_name' => $toolUse['name'] ?? null,
'tool_input' => isset($toolUse['input']) ? (array) $toolUse['input'] : null,
'tool_use_id' => $toolUse['id'] ?? null,
'raw_content' => $content, // full content array — required for tool_result flow
'input_tokens' => $inputTokens,
'output_tokens' => $outputTokens,
'latency_ms' => $latencyMs,
];
}
/**
* Send Claude the result of a tool call so it can formulate a spoken response.
*/
public function resumeWithToolResult(
array $messages,
string $toolUseId,
array $toolResult,
array $userContext,
string $language,
ModuleContextEnum $moduleContext
): array {
// Append tool_result block in the Claude API format
$messages[] = [
'role' => 'user',
'content' => [[
'type' => 'tool_result',
'tool_use_id' => $toolUseId,
'content' => json_encode($toolResult),
]],
];
return $this->complete($messages, $userContext, $language, $moduleContext);
}
private function composeSystemPrompt(array $userContext, string $language, ModuleContextEnum $moduleContext): string
{
$langInstruction = match($language) {
'ar' => 'ALWAYS respond in Arabic (Modern Standard Arabic with Gulf dialect awareness). Use short, clear sentences suitable for voice output.',
'en' => 'ALWAYS respond in English. Use short, clear sentences suitable for voice output.',
default => 'Detect the language the user used and respond in the same language (Arabic or English).',
};
$userName = $userContext['name'] ?? 'العميل';
$moduleLabel = $moduleContext->label();
$contextSection = '';
if (! empty($userContext['active_booking'])) {
$b = $userContext['active_booking'];
$contextSection .= "\n\nACTIVE BOOKING: #{$b['ref']} — Status: {$b['status']}, From: {$b['pickup']}, To: {$b['dropoff']}";
}
if (! empty($userContext['current_location'])) {
$contextSection .= "\n\nCUSTOMER GPS LOCATION: {$userContext['current_location']}";
$contextSection .= "\nUse this as the pickup for any fare or booking request unless the user explicitly names a different pickup.";
}
if (! empty($userContext['last_fare_estimate'])) {
$f = $userContext['last_fare_estimate'];
$types = implode(', ', $f['available_vehicle_types'] ?? []);
$contextSection .= "\n\nLAST FARE ESTIMATE: From \"{$f['pickup_address']}\" to \"{$f['destination_address']}\". Available vehicle types: {$types}.";
$contextSection .= "\nIf the user confirms booking, call book_ride immediately with these addresses. Do NOT ask for the route again.";
}
return <<<PROMPT
You are a professional, concise voice assistant for a UAE-based ride-booking and logistics platform.
Customer name: {$userName}
Current module: {$moduleLabel}
{$contextSection}
LANGUAGE RULE: {$langInstruction}
RESPONSE RULES:
- Keep responses under 2 sentences — they will be spoken aloud via TTS.
- NEVER use markdown formatting (no **, *, #, bullet points, or emojis) — plain text only.
- Never make up prices, ETAs, or booking references.
- If you cannot fulfill a request, say so in 1 sentence. Do NOT offer a menu of options.
BOOKING FLOW:
- If LAST FARE ESTIMATE is set and the user confirms booking, call book_ride immediately.
- After calling book_ride, confirm in 1 sentence.
TOOL USE:
- Only call tools when the user clearly requests an action.
- Never ask for information you already have in the context above.
- For destination names, ALWAYS call the tool even if vague (e.g. "المطار", "the mall"). The server resolves vague names using GPS. Never ask the customer to clarify a destination name.
PROMPT;
}
private function resolveAvailableTools(ModuleContextEnum $moduleContext): array
{
$allowed = collect(ModuleContextEnum::cases())
->filter(fn($ctx) => $ctx->isEnabled())
->flatMap(fn($ctx) => $ctx->availableTools())
->unique()
->all();
return array_values(array_filter(
$this->buildToolSchemas(),
fn($tool) => in_array($tool['name'], $allowed, true)
));
}
private function buildToolSchemas(): array
{
return [
[
'name' => 'get_fare_estimate',
'description' => "Get real-time fare estimates for a trip. The user's GPS is captured automatically — only ask for the destination.",
'input_schema' => [
'type' => 'object',
'properties' => [
'destination_address' => ['type' => 'string', 'description' => 'Destination address or landmark'],
'pickup_address' => ['type' => 'string', 'description' => 'Only set if user named a different pickup from their GPS position'],
'vehicle_type' => ['type' => 'string', 'enum' => ['economy', 'comfort', 'xl', 'premium']],
],
'required' => ['destination_address'],
],
],
[
'name' => 'book_ride',
'description' => 'Book a new ride. If LAST FARE ESTIMATE is set in context, use its addresses directly.',
'input_schema' => [
'type' => 'object',
'properties' => [
'pickup_address' => ['type' => 'string'],
'dropoff_address' => ['type' => 'string'],
'vehicle_type' => ['type' => 'string', 'enum' => ['economy', 'comfort', 'xl', 'premium']],
'scheduled_at' => ['type' => 'string', 'description' => 'ISO 8601 datetime for scheduled rides'],
],
'required' => ['dropoff_address'],
],
],
[
'name' => 'cancel_booking',
'description' => 'Cancel an existing booking.',
'input_schema' => [
'type' => 'object',
'properties' => [
'booking_ref' => ['type' => 'string', 'description' => 'Omit to cancel the most recent active booking.'],
'reason' => ['type' => 'string'],
],
'required' => [],
],
],
[
'name' => 'track_driver',
'description' => 'Get the current status and position of the assigned driver.',
'input_schema' => [
'type' => 'object',
'properties' => ['booking_ref' => ['type' => 'string']],
'required' => [],
],
],
[
'name' => 'get_booking_history',
'description' => "Get the customer's recent bookings.",
'input_schema' => [
'type' => 'object',
'properties' => [
'limit' => ['type' => 'integer'],
'status_filter' => ['type' => 'string', 'enum' => ['completed', 'cancelled', 'all']],
],
'required' => [],
],
],
[
'name' => 'get_current_location',
'description' => "Return the customer's GPS location as a human-readable address.",
'input_schema' => ['type' => 'object', 'properties' => (object)[], 'required' => []],
],
[
'name' => 'get_account_info',
'description' => 'Get the customer account summary (wallet balance, profile).',
'input_schema' => ['type' => 'object', 'properties' => (object)[], 'required' => []],
],
];
}
}The Two-Turn Tool Use Flow
This is the most important thing to understand about Claude's tool use API. When Claude decides to call a tool, it doesn't return a text response — it returns a tool_use content block. Your code must:
- Execute the tool.
- Append the full original assistant content (the
tool_useblock) to the message history. - Append the
tool_resultblock as a user message. - Call Claude again for the final spoken response.
Here's the flow in the controller:
// Step 7a — First call to Claude
$llmResult = $this->llm->complete(
messages: $history,
userContext: $userContext,
language: $languageDetected->value,
moduleContext: $dto->moduleContext,
);
if ($llmResult['tool_name']) {
// Step 7b — Execute the tool
[$toolResult, $status, $error] = $this->dispatchToolAction(
$llmResult['tool_name'],
$llmResult['tool_input'],
$user,
$session
);
// Step 7c — Append the assistant's tool_use block to history.
// raw_content must be used here (not just response_text) — Claude requires
// the tool_use block in the preceding assistant message before it will
// accept a tool_result in the user message.
$history[] = [
'role' => 'assistant',
'content' => $llmResult['raw_content'],
];
// Step 7d — Send tool result back; Claude formulates the spoken response
$finalResult = $this->llm->resumeWithToolResult(
messages: $history,
toolUseId: $llmResult['tool_use_id'],
toolResult: $toolResult ?? ['error' => $error],
userContext: $userContext,
language: $languageDetected->value,
moduleContext: $dto->moduleContext,
);
$responseText = $finalResult['response_text'];
} else {
$responseText = $llmResult['response_text'];
}The key mistake beginners make: they append only the text response to history, not raw_content. Claude's API will reject the next call with a validation error because there's a tool_result in the user message that doesn't have a corresponding tool_use in the preceding assistant message.
System Prompt Design for Voice
Designing a system prompt for voice output is different from chat. Three rules we enforce:
1. No markdown. Claude loves to respond with bold text, bullet points, and emojis. In TTS, **Economy** becomes the spoken words "asterisk asterisk Economy asterisk asterisk". The prompt explicitly forbids it.
2. Two-sentence limit. Spoken responses over two sentences feel like a lecture. We cap at two sentences and instruct Claude to give one-sentence confirmations after tool calls.
3. Never ask for information already in context. The system prompt injects the user's GPS location, last fare estimate, and active booking. Without an explicit instruction to use this data, Claude sometimes asks "What's your pickup location?" even when it's already there.
The Fare Estimate Memory Pattern
When the user asks for a fare estimate, we want the next "book it" message to work without asking for the route again. After a successful get_fare_estimate tool call, we save the result to session_metadata:
// After fare estimate succeeds — save to session so Claude has it on next turn
$session->update([
'session_metadata' => array_merge($session->session_metadata ?? [], [
'last_fare_estimate' => [
'pickup_address' => $result['pickup'],
'pickup_lat' => $pickupLat,
'pickup_lng' => $pickupLng,
'destination_address' => $result['destination'],
'available_vehicle_types' => array_map(
fn($e) => strtolower($e['vehicle_class']),
$estimates
),
],
]),
]);On the next turn, the system prompt injection looks like this:
LAST FARE ESTIMATE: From "Dubai Marina" to "DXB Airport". Available vehicle types: economy, comfort, xl.
If the user confirms booking, call book_ride immediately with these addresses. Do NOT ask for the route again.Claude reads this and knows to call book_ride with the stored addresses immediately — no follow-up question to the user.
What's Next
Part 4 closes the loop: the Cartesia text-to-speech service that converts Claude's response back into Arabic or English audio, the per-call cost tracking that shows you exactly what each conversation costs, and the rate limiting layer that keeps your API spend under control.
Need the API side sorted too? Mailgun's transactional email pairs naturally with a voice assistant — send booking confirmations by email while the assistant confirms by voice simultaneously.
Senior Full Stack Developer · Building SaaS products & teaching Laravel/React · 10+ years experience · Founder of Orion360 · Based in Dubai, UAE.
Was this post helpful?
Reviews & Ratings
Sign in to leave a review.