Skip to main content

Overview

Tinfoil provides a private web search capability that allows AI models to augment their responses with real-time web search results. The search agent runs inside a secure enclave and connects directly to Exa, a search provider with Zero Data Retention (ZDR). This architecture provides:
  • Query privacy from Tinfoil: The enclave generates queries and sends them directly to Exa over TLS. Tinfoil only sees encrypted traffic and never learns query contents.
  • User anonymity from search provider: All users share a single API key, and Exa only sees the enclave’s IP address, not individual user IPs.
  • Legal protections: Exa’s ZDR agreement ensures queries are never written to persistent storage or sent to external subprocessors.
  • Optional PII protection: A safeguard model blocks queries containing sensitive information before they’re sent to Exa. The model still responds, just without search results.
The web search proxy automatically:
  • Decides when a search is needed
  • Generates search queries from conversation context
  • Optionally blocks queries containing PII before sending to Exa
  • Returns citations with URLs, titles, and content snippets
Read our blog post on private AI web search to learn more!

Quick Start

Enable web search by adding web_search_options to your chat completion request. Optionally add pii_check_options to block queries containing sensitive information:
import { TinfoilAI } from 'tinfoil';

const client = new TinfoilAI({
  apiKey: process.env.TINFOIL_API_KEY
});

const stream = await client.chat.completions.create({
  model: '<MODEL_NAME>',
  messages: [
    { role: 'user', content: 'What are the latest developments in quantum computing?' }
  ],
  web_search_options: {},
  pii_check_options: {},  // Optional: blocks queries containing PII
  stream: true
});

// Process the stream
for await (const chunk of stream) {
  // Handle web search events (emitted first, before response content)
  if (chunk.type === 'web_search_call') {
    console.log(`Search status: ${chunk.status}`);
    if (chunk.action?.query) {
      console.log(`Query: ${chunk.action.query}`);
    }
    continue;
  }

  const delta = chunk.choices?.[0]?.delta;

  // Handle annotations (citations)
  if (delta?.annotations) {
    for (const annotation of delta.annotations) {
      if (annotation.type === 'url_citation') {
        const citation = annotation.url_citation;
        console.log(`\nCitation: ${citation.title} - ${citation.url}`);
      }
    }
  }

  // Handle search reasoning
  if (delta?.search_reasoning) {
    console.log(`\nSearch reasoning: ${delta.search_reasoning}`);
  }

  // Handle response content from the responder model
  if (delta?.content) {
    process.stdout.write(delta.content);
  }
}

Available Options

OptionRequiredDescription
web_search_optionsYesEnables web search capability
pii_check_optionsNoBlocks queries containing PII from being sent to Exa. The model still responds, just without search results.
PII filtering is optional. The pii_check_options field prevents queries containing sensitive information like SSNs, credit card numbers, and other PII from being sent to Exa. When a query is blocked, the model still responds to the user, but without search results. This serves as an extra layer of protection. Exa access is already covered by a ZDR agreement, and Tinfoil cannot view the user’s query from the secure enclave.

Example: Web Search Only

// Minimal setup - web search without PII blocking
const stream = await client.chat.completions.create({
  model: '<MODEL_NAME>',
  messages: [
    { role: 'user', content: 'What are the latest AI safety papers?' }
  ],
  web_search_options: {},
  stream: true
});

Example: Web Search with PII Protection

// Recommended - blocks queries containing PII
const stream = await client.chat.completions.create({
  model: '<MODEL_NAME>',
  messages: [
    { role: 'user', content: 'What are the latest AI safety papers?' }
  ],
  web_search_options: {},
  pii_check_options: {},  // Blocks queries with PII before sending to Exa
  stream: true
});

Response Format

Web Search Events

During streaming, the API emits web_search_call SSE events before the chat completion chunks begin. These events track search progress and are separate from the chat completion chunk structure:
{
  "type": "web_search_call",
  "id": "ws_abc123",
  "status": "in_progress",
  "action": {
    "type": "search",
    "query": "latest quantum computing breakthroughs 2026"
  }
}
Status Values:
  • in_progress - Search execution has started
  • completed - Search completed successfully and results are available
  • failed - Search encountered an error (includes reason field with error details)
  • blocked - Search was blocked by PII check (includes reason field explaining why)

Blocked Search Example

When PII is detected in a query:
{
  "type": "web_search_call",
  "id": "ws_def456",
  "status": "blocked",
  "reason": "SSN detected",
  "action": {
    "type": "search",
    "query": "search for John Smith SSN 123-45-6789"
  }
}
The query is blocked before being sent to Exa, but the response includes the full generated query text.

Annotations (Citations)

Citations are provided in the streaming delta’s annotations field:
{
  "choices": [{
    "delta": {
      "annotations": [{
        "type": "url_citation",
        "url_citation": {
          "title": "Quantum Computing Breakthrough Announced",
          "url": "https://example.com/article"
        }
      }]
    }
  }]
}

Search Reasoning

The search agent’s reasoning about search decisions is provided in the search_reasoning field:
{
  "choices": [{
    "delta": {
      "search_reasoning": "User is asking about recent developments, which requires current information. I will search for the latest quantum computing news from 2026."
    }
  }]
}

Blocked Searches

In non-streaming responses, blocked searches are listed in the blocked_searches field:
{
  "choices": [{
    "message": {
      "blocked_searches": [{
        "id": "ws_def456",
        "query": "search for account number 1234567890",
        "reason": "Bank account number detected"
      }]
    }
  }]
}
In streaming responses, blocked searches are emitted as web_search_call events with status: "blocked" instead.

Streaming Event Sequence

When using streaming mode, events are emitted in this order:
  1. Web search call events - One or more web_search_call SSE events with status blocked, in_progress, completed, or failed
  2. Metadata chunk - A single chat completion chunk containing annotations and search_reasoning (if any searches completed)
  3. Content chunks - Multiple chat completion chunks containing response text in the delta.content field
  4. Final chunk - A chunk with finish_reason: "stop" and empty delta
  5. Done signal - The data: [DONE] SSE message
Example sequence:
for await (const chunk of stream) {
  // First: web search events (not part of choices structure)
  if (chunk.type === 'web_search_call') {
    console.log(`Search ${chunk.status}: ${chunk.action?.query}`);
    continue;
  }

  const delta = chunk.choices?.[0]?.delta;

  // Second: metadata chunk (appears once before content)
  if (delta?.annotations || delta?.search_reasoning) {
    // Collect metadata
  }

  // Third: content chunks
  if (delta?.content) {
    process.stdout.write(delta.content);
  }

  // Fourth: final chunk
  if (chunk.choices?.[0]?.finish_reason === 'stop') {
    console.log('\nDone');
  }
}

Processing Citations

Citations in the response content use numbered markers (e.g., 【1】, 【2】) that correspond to the annotations:
let sources: Array<{title: string, url: string}> = [];

for await (const chunk of stream) {
  const delta = chunk.choices?.[0]?.delta;

  // Collect citations
  if (delta?.annotations) {
    for (const annotation of delta.annotations) {
      if (annotation.type === 'url_citation') {
        sources.push({
          title: annotation.url_citation.title,
          url: annotation.url_citation.url
        });
      }
    }
  }

  // Process content
  if (delta?.content) {
    // Content may include citation markers like 【1】
    let processed = delta.content;

    // Convert markers to links
    processed = processed.replace(/【(\d+)】/g, (match, num) => {
      const index = parseInt(num, 10) - 1;
      const source = sources[index];
      if (source) {
        return `[${num}](${source.url})`;
      }
      return match;
    });

    console.log(processed);
  }
}

Non-Streaming Usage

For non-streaming requests, all metadata is included in the final message:
const response = await client.chat.completions.create({
  model: '<MODEL_NAME>',
  messages: [
    { role: 'user', content: 'What is the capital of France?' }
  ],
  web_search_options: {},
  stream: false
});

const message = response.choices[0].message;

console.log(message.content);

// Access citations
if (message.annotations) {
  console.log('\nSources:');
  for (const annotation of message.annotations) {
    if (annotation.type === 'url_citation') {
      const cite = annotation.url_citation;
      console.log(`- ${cite.title}: ${cite.url}`);
    }
  }
}

// Access search reasoning
if (message.search_reasoning) {
  console.log('\nReasoning:', message.search_reasoning);
}

// Access blocked searches (if any)
if (message.blocked_searches) {
  console.log('\nBlocked Searches:');
  for (const blocked of message.blocked_searches) {
    console.log(`- Query: ${blocked.query}`);
    console.log(`  Reason: ${blocked.reason}`);
  }
}

PII Protection

The pii_check_options field prevents search queries containing sensitive personally identifiable information from being sent to Exa. When PII is detected, the query is blocked and the model responds without search results. Blocked PII types:
  • Government IDs: social security numbers, tax IDs, passport numbers, driver’s licenses, voter IDs, national IDs
  • Financial: bank account numbers, credit card numbers, IBANs
  • Contact: personal email addresses, personal phone numbers, home addresses
  • Linkable identifiers: VINs, license plates, device serial numbers
  • Identifying combinations: name + date of birth, name + address, or other combinations that identify a specific person
Not blocked:
  • Names alone
  • Dates of birth alone
  • Business/corporate contact information
  • Public figures’ public information
Generic descriptions without identifying details are allowed.