Skip to main content

Overview

After inferring the service type and collecting job details, Haggle uses OpenAI’s Responses API with web search to find local service providers. This eliminates the need to maintain a provider database and ensures users always get current, relevant results.
Provider search uses GPT-4o with the web_search_preview tool to find real businesses in the user’s area.

How It Works

1

User Answers Clarifying Questions

The frontend collects answers to all clarifying questions generated during task inference
2

Complete Job API Called

Answers are submitted to /api/complete-job along with the job ID
3

Search Prompt Generated

Haggle builds a structured search query from the job details
4

OpenAI Web Search Executed

The system calls OpenAI’s Responses API with web_search tool enabled
5

Results Parsed & Saved

Provider names and phone numbers are extracted and stored in Supabase

Implementation

Complete Job Endpoint

The /api/complete-job endpoint orchestrates the provider search:
@app.post("/api/complete-job", response_model=CompleteJobResponse)
async def complete_job(request: CompleteJobRequest):
    """
    Complete a job with clarification answers and search for providers.
    
    Flow:
    1. Retrieve job from memory
    2. Merge clarification answers into job
    3. Build search prompt from job JSON
    4. Call OpenAI Web Search
    5. Normalize and save providers to Supabase
    6. Return job + providers
    """
    # Step 1: Retrieve job from memory
    job = jobs_store.get(request.job_id)
    if not job:
        raise HTTPException(status_code=404, detail=f"Job not found: {request.job_id}")
    
    try:
        # Step 2: Merge clarification answers
        job.clarifications = request.answers
        job.status = JobStatus.READY_FOR_SEARCH
        
        # Step 3 & 4: Search for providers using OpenAI Web Search
        provider_creates = await search_providers(job)
        
        # Format context answers as a paragraph
        context_answers_text = format_context_answers(request.answers, job.questions)
        
        # Format problem statement using Grok LLM
        problem_statement = await format_problem_statement(job.original_query, job.task)
        
        # Parse price_limit to get max_price
        max_price = None
        if isinstance(job.price_limit, (int, float)):
            max_price = float(job.price_limit)
        elif isinstance(job.price_limit, str) and job.price_limit.lower() != "no_limit":
            try:
                max_price = float(job.price_limit)
            except ValueError:
                pass
        
        # Step 5: Save providers to Supabase
        saved_providers = []
        for pc in provider_creates:
            db_provider = Provider(
                job_id=pc.job_id,
                service_provider=pc.name,
                phone_number=pc.phone,
                context_answers=context_answers_text,
                house_address=job.house_address,
                zip_code=job.zip_code,
                max_price=max_price,
                problem=problem_statement,
                call_status="pending"  # Initialize as pending
            )
            
            created_provider = create_provider(db_provider)
            
            saved_providers.append(ProviderSchema(
                id=created_provider.id,
                job_id=created_provider.job_id,
                name=created_provider.service_provider,
                phone=created_provider.phone_number,
                estimated_price=created_provider.minimum_quote,
                negotiated_price=created_provider.negotiated_price,
                call_status=created_provider.call_status or "pending"
            ))
        
        # Update job status
        job.status = JobStatus.SEARCHED
        jobs_store[request.job_id] = job
        
        return CompleteJobResponse(
            job=job,
            providers=saved_providers
        )
        
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Error completing job: {str(e)}")

Search Prompt Construction

Haggle builds a targeted search prompt from the job details:
grok_search.py:31-51
def build_search_prompt(job: Job) -> str:
    """
    Construct a search prompt from the Job JSON.
    
    Args:
        job: The complete Job object with all clarifications
        
    Returns:
        A detailed search prompt for finding providers
    """
    prompt = f"""Find {job.task} services near zip code {job.zip_code}.

Search the web for local {job.task}s and provide a list with:
1. Business name
2. Phone number

Format each result as: NAME | PHONE

Find up to {MAX_PROVIDERS} providers near {job.zip_code}."""

    return prompt
Find plumber services near zip code 95126.

Search the web for local plumbers and provide a list with:
1. Business name
2. Phone number

Format each result as: NAME | PHONE

Find up to 5 providers near 95126.

OpenAI Web Search Service

The core search implementation uses OpenAI’s Responses API:
grok_search.py:54-111
async def search_providers(job: Job) -> List[ProviderCreate]:
    """
    Search for service providers using OpenAI web search.
    
    Runs the synchronous SDK in a thread pool to avoid blocking.
    
    Args:
        job: The complete Job object
        
    Returns:
        List of ProviderCreate objects with name, phone
    """
    # Use fallback if no API key is configured
    if not OPENAI_API_KEY:
        print("⚠️  No OPENAI_API_KEY set - using fallback providers")
        return _fallback_providers(job)
    
    # Run synchronous SDK call in thread pool
    loop = asyncio.get_event_loop()
    return await loop.run_in_executor(_executor, _sync_search_providers, job)


def _sync_search_providers(job: Job) -> List[ProviderCreate]:
    """
    Synchronous implementation of provider search using OpenAI web search.
    Called from thread pool to avoid blocking async event loop.
    """
    search_prompt = build_search_prompt(job)
    
    print(f"\n[OpenAI Search] Query:\n{search_prompt}\n", flush=True)
    
    try:
        # Initialize OpenAI Client
        client = OpenAI(api_key=OPENAI_API_KEY, organization=OPENAI_ORG_API_KEY)
        
        print("[OpenAI Search] Calling web_search tool...", flush=True)
        
        # Create response using web search tool
        response = client.responses.create(
            model="gpt-4o",
            tools=[{"type": "web_search_preview"}],
            input=search_prompt,
        )
        
        # Get the output text
        full_response = response.output_text
        
        print(f"\n[OpenAI Search] Response received ({len(full_response)} chars)")
        print(f"\n{full_response}\n", flush=True)
        
        # Parse providers from response
        providers = parse_provider_response(full_response, job.id)
        
        return providers if providers else _fallback_providers(job)
        
    except Exception as e:
        print(f"OpenAI Search API exception: {e}")
        return _fallback_providers(job)
The search runs in a thread pool executor to prevent blocking the async event loop, since the OpenAI SDK is synchronous.

Response Parsing

Haggle extracts provider information from the OpenAI response using regex:
grok_search.py:114-182
def parse_provider_response(content: str, job_id: str) -> List[ProviderCreate]:
    """
    Parse the response into ProviderCreate objects.
    
    Handles multiple formats:
    - NAME | PHONE
    - NAME - PHONE  
    - **NAME** | PHONE
    - 1. NAME | PHONE
    
    Args:
        content: Raw response content
        job_id: The job ID to associate with providers
        
    Returns:
        List of ProviderCreate objects
    """
    providers = []
    lines = content.strip().split("\n")
    
    # Phone number regex - matches (xxx) xxx-xxxx, xxx-xxx-xxxx, etc.
    phone_pattern = re.compile(r'\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}')
    
    for line in lines:
        line = line.strip()
        if not line:
            continue
        
        # Skip header-like lines
        if "name" in line.lower() and "phone" in line.lower():
            continue
        if line.startswith("---") or line.startswith("==="):
            continue
            
        # Try to find a phone number in the line
        phone_match = phone_pattern.search(line)
        if not phone_match:
            continue
            
        phone = phone_match.group()
        
        # Extract name - everything before the phone number, cleaned up
        name_part = line[:phone_match.start()]
        
        # Clean up the name
        name = name_part.strip()
        # Remove common prefixes/formatting
        name = re.sub(r'^[\d]+[.\)]\s*', '', name)  # Remove "1." or "1)"
        name = re.sub(r'^\*+', '', name)  # Remove leading asterisks
        name = re.sub(r'\*+$', '', name)  # Remove trailing asterisks
        name = re.sub(r'^[-|:]\s*', '', name)  # Remove leading dash/pipe/colon
        name = re.sub(r'[-|:]\s*$', '', name)  # Remove trailing dash/pipe/colon
        name = name.strip()
        
        # Skip if name is empty or too short
        if not name or len(name) < 3:
            continue
        
        # Skip if name looks like a header
        if name.lower() in ["name", "business", "provider", "company"]:
            continue
            
        providers.append(ProviderCreate(
            job_id=job_id,
            name=name,
            phone=phone
        ))
    
    return providers[:MAX_PROVIDERS]

Supported Formats

  • Reliable Plumbing | (408) 555-0101
  • **Quick Drain** - 408-555-0102
  • 1. Bay Area Plumbers | 408.555.0103
  • Master Plumbing: (408) 555-0104

Phone Patterns

  • (408) 555-0101
  • 408-555-0102
  • 408.555.0103
  • 4085550104

Fallback Providers

When the API is unavailable, Haggle uses task-specific mock data:
grok_search.py:185-243
def _fallback_providers(job: Job) -> List[ProviderCreate]:
    """
    Fallback provider data for development/testing when API is unavailable.
    
    Returns realistic-looking mock data based on the task type.
    """
    task = job.task.lower()
    zip_code = job.zip_code
    
    mock_providers = {
        "plumber": [
            ("Reliable Plumbing Services", "(408) 555-0101", 150.0),
            ("Quick Drain Solutions", "(408) 555-0102", 125.0),
            ("Bay Area Master Plumbers", "(408) 555-0103", 175.0),
            ("24/7 Emergency Plumbing", "(408) 555-0104", 200.0),
            ("Budget Plumbing Co.", "(408) 555-0105", 100.0),
        ],
        "electrician": [
            ("Bright Spark Electric", "(408) 555-0201", 175.0),
            ("Safe Home Electrical", "(408) 555-0202", 150.0),
            ("PowerUp Electricians", "(408) 555-0203", 200.0),
            ("Circuit Masters", "(408) 555-0204", 165.0),
            ("Volt Electric Services", "(408) 555-0205", 140.0),
        ],
        # ... more task types
    }
    
    # Get task-specific or default providers
    default_providers = [
        ("Local Service Pro #1", "(408) 555-0001", 150.0),
        ("Trusted Handyman Services", "(408) 555-0002", 125.0),
        ("Quick Fix Solutions", "(408) 555-0003", 175.0),
        ("Reliable Home Services", "(408) 555-0004", 140.0),
        ("Expert Service Co.", "(408) 555-0005", 160.0),
    ]
    
    provider_list = mock_providers.get(task, default_providers)
    
    return [
        ProviderCreate(
            job_id=job.id,
            name=name,
            phone=phone
        )
        for name, phone, _ in provider_list[:MAX_PROVIDERS]
    ]

Database Storage

Providers are stored in Supabase with all necessary context for the voice agent:
class Provider:
    id: int
    job_id: str
    service_provider: str  # Business name
    phone_number: str
    context_answers: str  # Formatted Q&A paragraph
    house_address: str
    zip_code: str
    max_price: float
    problem: str  # Formatted problem statement
    call_status: str  # pending, in_progress, completed, failed
    negotiated_price: float  # Set after successful call
    call_transcript: str  # Full conversation transcript

Provider Status Tracking

The system tracks each provider’s call status:
@app.get("/api/providers/{job_id}/status")
async def get_providers_status(job_id: str):
    """
    Get all providers for a job with their call status and negotiated prices.
    This endpoint is optimized for polling by the frontend.
    """
    providers = get_providers_by_job_id(job_id)
    return [
        {
            "id": p.id,
            "job_id": p.job_id,
            "name": p.service_provider,
            "phone": p.phone_number,
            "estimated_price": p.minimum_quote,
            "negotiated_price": p.negotiated_price,
            "call_status": p.call_status or "pending",
            "call_transcript": p.call_transcript
        }
        for p in providers
    ]

User Workflow

1

Submit Job Details

User submits initial query and answers clarifying questions
2

Providers Found

OpenAI web search returns 3-5 local providers
3

Review Results

User sees list of providers with names and phone numbers
4

Start Calls

User clicks “Start Negotiating” to begin automated calls
5

Monitor Progress

Frontend polls /api/providers/{job_id}/status to show real-time updates
6

Compare Offers

After calls complete, user sees negotiated prices and selects best option

Integration Example

Frontend Integration
// Step 1: Complete job with answers
const completeResponse = await fetch('/api/complete-job', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    job_id: jobId,
    answers: {
      q1: "The toilet is constantly running",
      q2: "Yes, water runs non-stop",
      q3: "About 10 years old"
    }
  })
});

const { job, providers } = await completeResponse.json();
console.log(`Found ${providers.length} providers`);

// Step 2: Display providers
providers.forEach(provider => {
  console.log(`${provider.name} - ${provider.phone}`);
});

// Step 3: Start automated calls
await fetch(`/api/start-calls/${jobId}`, { method: 'POST' });

// Step 4: Poll for status updates
const pollStatus = setInterval(async () => {
  const statusResponse = await fetch(`/api/providers/${jobId}/status`);
  const statuses = await statusResponse.json();
  
  statuses.forEach(p => {
    console.log(`${p.name}: ${p.call_status}`);
    if (p.negotiated_price) {
      console.log(`  Negotiated: $${p.negotiated_price}`);
    }
  });
  
  // Stop polling when all calls complete
  if (statuses.every(p => ['completed', 'failed'].includes(p.call_status))) {
    clearInterval(pollStatus);
  }
}, 3000);

Benefits

Always Current

Web search finds active businesses, no stale database

Wide Coverage

Works for any location and service type

No Maintenance

No need to maintain provider listings or verify contacts

Structured Output

GPT-4o follows formatting instructions for easy parsing

AI Task Inference

Learn how Haggle classifies service requests

Automated Negotiation

See how the voice agent calls and negotiates with providers

Build docs developers (and LLMs) love