> ## Documentation Index
> Fetch the complete documentation index at: https://docs.keenable.ai/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> Keenable search and fetch work without an API key. To add them to code, use the official SDK for the project's language: https://docs.keenable.ai/sdks.md
> Read an API key from the KEENABLE_API_KEY environment variable. Never ask a user to paste a key into chat; send them to https://docs.keenable.ai/api-keys.md
> On 402 with an API key (code insufficient_credits), stop and ask a human to add credits; don't retry. On the pay-per-request paths a 402 is the payment offer: pay it and retry, see https://docs.keenable.ai/pay-per-request.md. On 429, wait the number of seconds in Retry-After, then retry.
> Every error, with its cause and fix: https://docs.keenable.ai/errors.md

# Tool calling

> Give an LLM web search and page fetch as tools. Pass the SDK's TOOLS to any OpenAI-compatible chat completions client and run each call with run_tool_call.

Your model decides when to search the web or read a page, and your code runs the call with one helper. The SDKs ship the tool definitions and the helper, so you write only the loop.

## What you get

`TOOLS` is a list of two tool definitions in OpenAI function-calling format. Pass it as `tools` to any chat completions client that accepts that format.

| Tool | What the model can do | Arguments |
| - | - | - |
| `keenable_search` | Search the web. The model gets ranked pages with title, URL and extracted page text. | `query` (required), `site`, `published_after` |
| `keenable_fetch` | Read one page as markdown. | `url` (required) |

`run_tool_call` runs whichever of the two tools the model picked and returns the text to send back. Each SDK names the pieces in its own style:

| | TypeScript | Python | Go (package `keenable`) |
| - | - | - | - |
| Both definitions | `TOOLS` | `TOOLS` | `Tools` |
| One definition | `SEARCH_TOOL`, `FETCH_TOOL` | `SEARCH_TOOL`, `FETCH_TOOL` | `SearchTool`, `FetchTool` |
| Run a tool call | `runToolCall` | `run_tool_call` (`arun_tool_call` with `AsyncKeenable`) | `RunToolCall` |

The programs on this page are in TypeScript and Python. In Go, `keenable.RunToolCall(ctx, client, name, arguments)` returns `(string, error)` and takes the same tool names and arguments.

## Run the tool-calling loop

The program below asks a question, lets the model call the Keenable tools as often as it needs, and prints the final answer. It uses the official `openai` client, which works with any provider that has an OpenAI-compatible chat completions endpoint. Keenable needs no API key here.

**1. Install the packages**

Install the Keenable SDK and the `openai` client:

```bash TypeScript theme={"system"}
npm install keenable openai
```

```bash Python theme={"system"}
pip install keenable openai
```

**2. Point the client at your LLM provider**

The `openai` client reads the key and the base URL from the environment. The program reads the model name from `OPENAI_MODEL`. Use your provider's values:

```bash theme={"system"}
export OPENAI_API_KEY=<your_provider_key>
export OPENAI_BASE_URL=<your_provider_base_url>
export OPENAI_MODEL=<a_model_that_supports_tool_calling>
```

To call OpenAI itself, leave `OPENAI_BASE_URL` unset.

**3. Save the program**

Save this program as `tool-calling.mts` or `tool_calling.py`:

```typescript TypeScript theme={"system"}
import { Keenable, KeenableError, TOOLS, runToolCall } from "keenable";
import OpenAI from "openai";

const keenable = new Keenable();
const llm = new OpenAI(); // reads OPENAI_API_KEY and OPENAI_BASE_URL

const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [
  {
    role: "system",
    content:
      "Use the web tools when the answer depends on recent information. " +
      "Cite sources by their number, like [1].",
  },
  { role: "user", content: "What is the latest version of the Model Context Protocol specification?" },
];

while (true) {
  const completion = await llm.chat.completions.create({
    model: process.env.OPENAI_MODEL!,
    messages,
    tools: TOOLS,
  });
  const message = completion.choices[0].message;
  if (!message.tool_calls?.length) {
    console.log(message.content);
    break;
  }

  messages.push(message);
  for (const call of message.tool_calls) {
    if (call.type !== "function") continue;
    let output: string;
    try {
      output = await runToolCall(keenable, call.function.name, call.function.arguments);
    } catch (error) {
      output = `Tool call failed: ${error instanceof KeenableError ? error.message : error}`;
    }
    messages.push({ role: "tool", tool_call_id: call.id, content: output });
  }
}
```

```python Python theme={"system"}
import os

from keenable import TOOLS, Keenable, KeenableError, run_tool_call
from openai import OpenAI

keenable = Keenable()
llm = OpenAI()  # reads OPENAI_API_KEY and OPENAI_BASE_URL

messages = [
    {
        "role": "system",
        "content": "Use the web tools when the answer depends on recent "
        "information. Cite sources by their number, like [1].",
    },
    {"role": "user", "content": "What is the latest version of the Model Context Protocol specification?"},
]

while True:
    completion = llm.chat.completions.create(
        model=os.environ["OPENAI_MODEL"], messages=messages, tools=TOOLS
    )
    message = completion.choices[0].message
    if not message.tool_calls:
        print(message.content)
        break

    messages.append(message)
    for call in message.tool_calls:
        try:
            output = run_tool_call(keenable, call.function.name, call.function.arguments)
        except KeenableError as exc:
            output = f"Tool call failed: {exc}"
        messages.append({"role": "tool", "tool_call_id": call.id, "content": output})
```

Each pass of the loop does three things:

1. Sends the conversation and `TOOLS` to the model.
2. If the model answers without a tool call, prints the answer and stops.
3. Otherwise runs every tool call with `run_tool_call` and appends each result as a `role: "tool"` message with the matching `tool_call_id`.

**4. Run it**

```bash TypeScript theme={"system"}
npx tsx tool-calling.mts
```

```bash Python theme={"system"}
python tool_calling.py
```

The program prints the model's final message. The system prompt asks the model to cite the tool results by number, such as `[1]`.

## What a tool call returns

`run_tool_call` takes the client, the tool name and the arguments, and returns one string. The arguments can be the raw JSON string from the model or an already-decoded object.

This program runs a search tool call by hand, with no model and no key:

```typescript TypeScript theme={"system"}
import { Keenable, runToolCall } from "keenable";

const output = await runToolCall(
  new Keenable(),
  "keenable_search",
  '{"query": "Model Context Protocol specification", "site": "modelcontextprotocol.io"}',
);
console.log(output);
```

```python Python theme={"system"}
from keenable import Keenable, run_tool_call

output = run_tool_call(
    Keenable(),
    "keenable_search",
    '{"query": "Model Context Protocol specification", "site": "modelcontextprotocol.io"}',
)
print(output)
```

You see the same numbered block that `to_context()` renders, one `[n] Title (url)` header per result followed by the page text:

```text Example output · results vary theme={"system"}
[1] Specification (https://modelcontextprotocol.io/specification/2025-03-26) - published 2025-03-26T00:00:00Z
[Model Context Protocol](https://modelcontextprotocol.io) (MCP) is an open protocol that…

[2] Overview (https://modelcontextprotocol.io/specification/2025-03-26/basic) - published 2025-03-26T00:00:00Z
The Model Context Protocol consists of several key components that work together: …

[3] Tools (https://modelcontextprotocol.io/specification/2025-11-25/server/tools) - published 2025-11-25T00:00:00Z
…
```

A `keenable_fetch` call returns the same shape with a single `[1] Title (url)` entry, so the model cites a fetched page the way it cites a search result. Both blocks are limited to 12,000 characters. See [Context size](https://docs.keenable.ai/sdks.md#context-size).

For `keenable_search`, the helper passes on only `query`, `site` and `published_after`. It drops any other argument the model sends.

## Errors inside a tool call

`run_tool_call` raises a `KeenableError` instead of returning an error string. The loop above catches it and sends the message back as the tool result, so the model can retry with other arguments or another URL.

| When | Error | Example message (Python) |
| - | - | - |
| The model names a tool other than the two | `KeenableInvalidRequestError` | `unknown Keenable tool: 'web_search'` |
| The arguments are not a JSON object | `KeenableInvalidRequestError` | `tool call arguments are not valid JSON: 'not json'` |
| `query` or `url` is missing or empty | `KeenableInvalidRequestError` | `keenable_search needs a 'query'` |
| The API returns an error, such as a page that can't be fetched | `KeenableAPIError` and the other [SDK error classes](https://docs.keenable.ai/sdks.md#errors) | `Keenable API error (502): …` |

In the first three cases the SDK sends no request. If the API returns `429`, see [Rate limits](https://docs.keenable.ai/rate-limits.md). An [API key](https://docs.keenable.ai/api-keys.md) removes the keyless hourly cap.

## Ground an answer without tool calling

If your model has no tool calling, or you want one search per question, search first and put the `to_context()` block in the prompt. This program uses the same environment variables as the tool-calling loop:

```typescript TypeScript theme={"system"}
import { Keenable } from "keenable";
import OpenAI from "openai";

const question = "What is the latest version of the Model Context Protocol specification?";

const results = await new Keenable().search(question);
const llm = new OpenAI(); // reads OPENAI_API_KEY and OPENAI_BASE_URL

const completion = await llm.chat.completions.create({
  model: process.env.OPENAI_MODEL!,
  messages: [
    {
      role: "system",
      content:
        "Answer using only the sources provided. Cite them by their number, " +
        "like [1]. If the sources do not answer the question, say so.",
    },
    { role: "user", content: `${results.toContext()}\n\nQuestion: ${question}` },
  ],
});
console.log(completion.choices[0].message.content);

console.log("\nSources:");
results.cited().forEach((result, index) => {
  console.log(`[${index + 1}] ${result.title} - ${result.url}`);
});
```

```python Python theme={"system"}
import os

from keenable import Keenable
from openai import OpenAI

question = "What is the latest version of the Model Context Protocol specification?"

results = Keenable().search(question)
llm = OpenAI()  # reads OPENAI_API_KEY and OPENAI_BASE_URL

completion = llm.chat.completions.create(
    model=os.environ["OPENAI_MODEL"],
    messages=[
        {
            "role": "system",
            "content": "Answer using only the sources provided. Cite them by "
            "their number, like [1]. If the sources do not answer the "
            "question, say so.",
        },
        {"role": "user", "content": f"{results.to_context()}\n\nQuestion: {question}"},
    ],
)
print(completion.choices[0].message.content)

print("\nSources:")
for index, result in enumerate(results.cited(), start=1):
    print(f"[{index}] {result.title} - {result.url}")
```

The program prints the model's answer, then the sources behind each `[n]` marker:

```text Example output · results vary theme={"system"}
<the model's answer>

Sources:
[1] Specification - https://modelcontextprotocol.info/specification
[2] The New MCP Roadmap - https://blog.modelcontextprotocol.io/posts/mcp-roadmap
[3] Model Context Protocol Blog - https://blog.modelcontextprotocol.io
…
```

`cited()` returns the results that `to_context()` kept, in the same order, so the list always matches the numbers the model saw. To narrow the search, pass the [Search parameters](https://docs.keenable.ai/products/search.md#key-parameters) to `search`.

## Use the tools with Anthropic's Messages API

Anthropic's Messages API takes the same JSON schema under different field names. Map each definition to `name`, `description` and `input_schema`, then run each `tool_use` block with `run_tool_call` and send the result back as a `tool_result` block.

This program runs the same loop with the `anthropic` SDK. Install it with `npm install keenable @anthropic-ai/sdk` or `pip install keenable anthropic`, and set `ANTHROPIC_API_KEY`:

```typescript TypeScript theme={"system"}
import Anthropic from "@anthropic-ai/sdk";
import { Keenable, KeenableError, TOOLS, runToolCall } from "keenable";

const keenable = new Keenable();
const client = new Anthropic(); // reads ANTHROPIC_API_KEY

// Convert each OpenAI-format definition to Anthropic's tool format.
const tools = TOOLS.map((tool) => ({
  name: tool.function.name,
  description: tool.function.description,
  input_schema: tool.function.parameters as Anthropic.Tool.InputSchema,
}));

const messages: Anthropic.MessageParam[] = [
  { role: "user", content: "What is the latest version of the Model Context Protocol specification?" },
];

let response: Anthropic.Message;
while (true) {
  response = await client.messages.create({
    model: "claude-opus-5-5",
    max_tokens: 16000,
    tools,
    messages,
  });
  if (response.stop_reason !== "tool_use") break;

  messages.push({ role: "assistant", content: response.content });
  const results: Anthropic.ToolResultBlockParam[] = [];
  for (const block of response.content) {
    if (block.type !== "tool_use") continue;
    try {
      const content = await runToolCall(keenable, block.name, block.input as Record<string, unknown>);
      results.push({ type: "tool_result", tool_use_id: block.id, content });
    } catch (error) {
      const content = `Tool call failed: ${error instanceof KeenableError ? error.message : error}`;
      results.push({ type: "tool_result", tool_use_id: block.id, content, is_error: true });
    }
  }
  messages.push({ role: "user", content: results });
}

for (const block of response.content) {
  if (block.type === "text") console.log(block.text);
}
```

```python Python theme={"system"}
import anthropic
from keenable import TOOLS, Keenable, KeenableError, run_tool_call

keenable = Keenable()
client = anthropic.Anthropic()  # reads ANTHROPIC_API_KEY

# Convert each OpenAI-format definition to Anthropic's tool format.
tools = [
    {
        "name": tool["function"]["name"],
        "description": tool["function"]["description"],
        "input_schema": tool["function"]["parameters"],
    }
    for tool in TOOLS
]

messages = [
    {"role": "user", "content": "What is the latest version of the Model Context Protocol specification?"}
]

while True:
    response = client.messages.create(
        model="claude-opus-5-5", max_tokens=16000, tools=tools, messages=messages
    )
    if response.stop_reason != "tool_use":
        break

    messages.append({"role": "assistant", "content": response.content})
    results = []
    for block in response.content:
        if block.type != "tool_use":
            continue
        result = {"type": "tool_result", "tool_use_id": block.id}
        try:
            result["content"] = run_tool_call(keenable, block.name, block.input)
        except KeenableError as exc:
            result["content"] = f"Tool call failed: {exc}"
            result["is_error"] = True
        results.append(result)
    messages.append({"role": "user", "content": results})

for block in response.content:
    if block.type == "text":
        print(block.text)
```

The program prints the text of the model's final message. If a tool call fails, the `tool_result` block carries `is_error: true` and the error message.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.