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

# Knowledge for agents

> Let an agent answer from your documents: a Smart Memory collection, or a knowledge base that stays on your own server

## Overview

An agent can look things up in your documents while it talks. There are two sources. An agent uses **one** of them.

| | Smart Memory collection | Your own server |
| - | - | - |
| Agent field | `knowledge_collection_id` | `knowledge_webhook_url` |
| Where documents live | 60db [Memory](https://docs.60db.ai/features/memory.md) | On your machine. Nothing is copied to 60db |
| You run | Nothing | One HTTPS endpoint |
| Billing | Searches billed as [Memory searches](https://docs.60db.ai/api-reference/memory/pricing.md) | 60db does not bill webhook searches |

Setting both on one agent returns `422 KNOWLEDGE_SOURCE_CONFLICT`. Pick one.

```
caller speaks ──► 60db ──(background search)──► collection  or  your endpoint ──► {"passages": [...]}
                    └─► the next reply reads the newest passages already in memory
```

### Why it adds no delay to a reply

A search takes longer than a turn, so **no reply ever waits for one**. 60db searches in the background: once at the start with the agent's script, then on the caller's words while they are still talking. A reply reads the newest result that has already arrived, from memory, with no network call.

* A slow or failing source never delays or fails a call. A failed or timed-out current search clears its passages, so the next reply does not reuse notes from an older question.
* The first reply after a sudden change of topic can use the previous topic's passages. The new ones arrive a moment later and are used from the reply after.

See also [Agents](https://docs.60db.ai/features/agents.md), [Memory](https://docs.60db.ai/features/memory.md), [Bring your own SIP](https://docs.60db.ai/features/agents-bring-your-own-sip.md) and [Move from LiveKit or Pipecat](https://docs.60db.ai/features/agents-migrate-from-livekit-pipecat.md).

## A. Use a Smart Memory collection

**1. Create a workspace collection**

`POST /memory/collections` with `kind: "knowledge"`. Owner or admin only. See [Collections](https://docs.60db.ai/api-reference/memory/collections.md)

**2. Ingest your documents**

`POST /memory/ingest` with `type: "knowledge"` and the collection. Type `knowledge` is **admin or owner only**. See [Ingest](https://docs.60db.ai/api-reference/memory/ingest.md). To read files first, use [Extract document](https://docs.60db.ai/api-reference/memory/extract-document.md)

**3. Attach the collection to the agent**

Set `knowledge_collection_id` to the collection's UUID (the `hash_id` that [List collections](https://docs.60db.ai/api-reference/memory/collections.md) returns) on [Create agent](https://docs.60db.ai/api-reference/agents/create-agent.md) or [Update agent](https://docs.60db.ai/api-reference/agents/update-agent.md)

**4. Verify with a test call**

Open the agent, choose **Test call**, **Talk in browser**, and ask about something only your documents cover

```bash theme={null}
# 1. Create the collection (owner or admin)
curl -X POST https://api.60db.ai/memory/collections \
  -H "Authorization: Bearer $SIXTYDB_API_KEY" -H "Content-Type: application/json" \
  -d '{ "collection_id": "bistro_faq", "label": "Bistro FAQ", "kind": "knowledge", "shared": true }'

# 2. Ingest a document (admin or owner only for type "knowledge")
curl -X POST https://api.60db.ai/memory/ingest \
  -H "Authorization: Bearer $SIXTYDB_API_KEY" -H "Content-Type: application/json" \
  -d '{
    "text": "Open 9 to 5, Monday to Saturday. Closed on Sundays.",
    "title": "Opening hours",
    "type": "knowledge",
    "collection": "bistro_faq"
  }'

# 3. Find the collection UUID (hash_id)
curl https://api.60db.ai/memory/collections -H "Authorization: Bearer $SIXTYDB_API_KEY"

# 4. Attach it to the agent
curl -X PATCH https://api.60db.ai/agents/AGENT_ID \
  -H "Authorization: Bearer $SIXTYDB_API_KEY" -H "Content-Type: application/json" \
  -d '{ "knowledge_collection_id": "COLLECTION_UUID" }'
```

> [tab] Node.js
>
> ```javascript theme={null}
> const headers = {
>   Authorization: `Bearer ${process.env.SIXTYDB_API_KEY}`,
>   "Content-Type": "application/json",
> };
> const api = "https://api.60db.ai";
>
> await fetch(`${api}/memory/ingest`, {
>   method: "POST",
>   headers,
>   body: JSON.stringify({
>     text: "Open 9 to 5, Monday to Saturday. Closed on Sundays.",
>     title: "Opening hours",
>     type: "knowledge",
>     collection: "bistro_faq",
>   }),
> }).then((r) => { if (!r.ok) throw new Error(`ingest failed: ${r.status}`); });
>
> const res = await fetch(`${api}/agents/${agentId}`, {
>   method: "PATCH",
>   headers,
>   body: JSON.stringify({ knowledge_collection_id: collectionUuid }),
> });
> if (!res.ok) throw new Error(`attach failed: ${res.status} ${await res.text()}`);
> ```

> [tab] Python
>
> ```python theme={null}
> import os, requests
>
> API = "https://api.60db.ai"
> H = {"Authorization": f"Bearer {os.environ['SIXTYDB_API_KEY']}"}
>
> r = requests.post(f"{API}/memory/ingest", headers=H, timeout=30, json={
>     "text": "Open 9 to 5, Monday to Saturday. Closed on Sundays.",
>     "title": "Opening hours",
>     "type": "knowledge",
>     "collection": "bistro_faq",
> })
> r.raise_for_status()
>
> r = requests.patch(f"{API}/agents/{agent_id}", headers=H, timeout=30,
>                    json={"knowledge_collection_id": collection_uuid})
> r.raise_for_status()
> ```

### Rules and errors

| Status | Code | Meaning |
| - | - | - |
| 404 | `KNOWLEDGE_COLLECTION_NOT_FOUND` | The collection is not in your workspace |
| 422 | `KNOWLEDGE_COLLECTION_UNSUPPORTED` | The collection is a **personal** one (`User_...`). Use a workspace collection (`team`, `knowledge` or `hive`) |
| 422 | `KNOWLEDGE_SOURCE_CONFLICT` | `knowledge_webhook_url` is also set. Use one source |

Send `knowledge_collection_id: null` to detach.

## B. A knowledge base on your own server

If the documents live on your machine, the agent asks your server for them during the call. Nothing is copied to 60db. Set `knowledge_webhook_url` on the agent and run one HTTP endpoint.

| Field | Meaning |
| - | - |
| `knowledge_webhook_url` | HTTPS URL of your search endpoint, set on [Create agent](https://docs.60db.ai/api-reference/agents/create-agent.md) or [Update agent](https://docs.60db.ai/api-reference/agents/update-agent.md). `null` clears it. No credentials in the URL. The URL follows the same safety rules as `tool_webhook_url`: a URL that is not public HTTPS returns `422 UNSAFE_WEBHOOK_URL` |

There is **no separate secret field**. Requests are signed with the agent's existing `whsec_...` webhook secret, the same one that signs [tool calls and call events](https://docs.60db.ai/features/agents-tools-and-webhooks.md#verify-signatures). It is shown when the agent is created and when you [rotate it](https://docs.60db.ai/api-reference/agents/rotate-webhook-signing.md). One verifier serves tools, events and knowledge.

60db calls your endpoint itself. Your URL is re-checked for safety on every call, so a URL that later resolves to a private address is refused.

### What your endpoint receives

```
POST https://kb.example.com/search
content-type: application/json
x-60db-timestamp: 1762165200
x-60db-signature: v1=4368c1cf38f7445b9bbc2a16fbf91484317d073486cbc179e62acb15365eb2a7

{"max_results":4,"query":"opening hours on sunday"}
```

* `query` is the caller's latest words, at most **240 characters**.
* `max_results` is **4**.

### What it answers

Status `200` and:

```json theme={null}
{ "passages": ["Open 9 to 5, Monday to Saturday.", { "text": "Closed on Sundays." }] }
```

Each passage is a string or `{ "text": "..." }`. At most **4** are used. Answer with whole paragraphs, about 2,000 characters each at most. Any other status, a body that is not this shape, or an answer over 256 KB counts as a failed search and returns no passages.

### Limits

| Limit | Value |
| - | - |
| Time for a search | The agent waits at most **2 seconds** for a search. 60db gives your server **1.5 seconds** to answer, so reply well inside that |
| Searches per call | At most **150** |
| Query | 240 characters |
| Passages | 4 per search |
| Failure | Any failure returns no passages. It never fails or delays the call |

### Verify the signature

The signature is `v1=` followed by the hex HMAC-SHA256, keyed with the agent's `whsec_...` secret, of `{timestamp}.{raw request body}`. Use the **raw bytes** of the body, not re-serialised JSON. Reject a timestamp more than a few minutes old, which stops a captured request being replayed.

> [tab] Node.js
>
> ```javascript theme={null}
> import crypto from "node:crypto";
> import http from "node:http";
>
> const SECRET = process.env.SIXTYDB_WEBHOOK_SECRET; // whsec_...
> const TOLERANCE_SECONDS = 300;
>
> function verify(raw, timestamp, signature) {
>   const age = Math.abs(Date.now() / 1000 - Number(timestamp));
>   if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;
>   const expected = "v1=" + crypto.createHmac("sha256", SECRET)
>     .update(`${timestamp}.`).update(raw).digest("hex");
>   const a = Buffer.from(expected);
>   const b = Buffer.from(String(signature ?? ""));
>   return a.length === b.length && crypto.timingSafeEqual(a, b);
> }
>
> async function search(query, max) {
>   return ["Open 9 to 5, Monday to Saturday."].slice(0, max); // your retrieval here
> }
>
> http.createServer((req, res) => {
>   const chunks = [];
>   req.on("data", (c) => chunks.push(c));
>   req.on("end", async () => {
>     const raw = Buffer.concat(chunks);
>     if (!verify(raw, req.headers["x-60db-timestamp"], req.headers["x-60db-signature"])) {
>       res.writeHead(401).end();
>       return;
>     }
>     try {
>       const { query, max_results } = JSON.parse(raw.toString("utf8"));
>       const passages = await search(String(query), Number(max_results) || 4);
>       res.writeHead(200, { "content-type": "application/json" });
>       res.end(JSON.stringify({ passages }));
>     } catch {
>       res.writeHead(400).end();
>     }
>   });
> }).listen(8080);
> ```

> [tab] Python
>
> ```python theme={null}
> import hashlib, hmac, json, os, time
> from http.server import BaseHTTPRequestHandler, HTTPServer
>
> SECRET = os.environ["SIXTYDB_WEBHOOK_SECRET"].encode()  # whsec_...
> TOLERANCE_SECONDS = 300
>
>
> def verify(raw: bytes, timestamp: str, signature: str) -> bool:
>     try:
>         if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
>             return False
>     except (TypeError, ValueError):
>         return False
>     expected = "v1=" + hmac.new(SECRET, timestamp.encode() + b"." + raw, hashlib.sha256).hexdigest()
>     return hmac.compare_digest(expected, signature or "")
>
>
> def search(query: str, max_results: int) -> list[str]:
>     return ["Open 9 to 5, Monday to Saturday."][:max_results]  # your retrieval here
>
>
> class Handler(BaseHTTPRequestHandler):
>     def do_POST(self):
>         raw = self.rfile.read(int(self.headers.get("Content-Length", 0)))
>         if not verify(raw, self.headers.get("X-60db-Timestamp"), self.headers.get("X-60db-Signature")):
>             self.send_response(401); self.end_headers(); return
>         try:
>             body = json.loads(raw)
>             passages = search(str(body["query"]), int(body.get("max_results", 4)))
>         except (ValueError, KeyError):
>             self.send_response(400); self.end_headers(); return
>         out = json.dumps({"passages": passages}).encode()
>         self.send_response(200)
>         self.send_header("Content-Type", "application/json")
>         self.send_header("Content-Length", str(len(out)))
>         self.end_headers()
>         self.wfile.write(out)
>
>
> HTTPServer(("", 8080), Handler).serve_forever()
> ```

> [tab] Go
>
> ```go theme={null}
> package main
>
> import (
> 	"crypto/hmac"
> 	"crypto/sha256"
> 	"encoding/hex"
> 	"encoding/json"
> 	"io"
> 	"log"
> 	"math"
> 	"net/http"
> 	"os"
> 	"strconv"
> 	"time"
> )
>
> var secret = []byte(os.Getenv("SIXTYDB_WEBHOOK_SECRET")) // whsec_...
>
> const toleranceSeconds = 300
>
> func verify(raw []byte, timestamp, signature string) bool {
> 	ts, err := strconv.ParseInt(timestamp, 10, 64)
> 	if err != nil || math.Abs(float64(time.Now().Unix()-ts)) > toleranceSeconds {
> 		return false
> 	}
> 	mac := hmac.New(sha256.New, secret)
> 	mac.Write([]byte(timestamp + "."))
> 	mac.Write(raw)
> 	expected := "v1=" + hex.EncodeToString(mac.Sum(nil))
> 	return hmac.Equal([]byte(expected), []byte(signature))
> }
>
> func search(query string, max int) []string {
> 	return []string{"Open 9 to 5, Monday to Saturday."} // your retrieval here
> }
>
> func handler(w http.ResponseWriter, r *http.Request) {
> 	raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<16))
> 	if err != nil {
> 		http.Error(w, "bad request", http.StatusBadRequest)
> 		return
> 	}
> 	if !verify(raw, r.Header.Get("X-60db-Timestamp"), r.Header.Get("X-60db-Signature")) {
> 		http.Error(w, "unauthorized", http.StatusUnauthorized)
> 		return
> 	}
> 	var body struct {
> 		Query      string `json:"query"`
> 		MaxResults int    `json:"max_results"`
> 	}
> 	if err := json.Unmarshal(raw, &body); err != nil {
> 		http.Error(w, "bad request", http.StatusBadRequest)
> 		return
> 	}
> 	w.Header().Set("Content-Type", "application/json")
> 	json.NewEncoder(w).Encode(map[string]any{"passages": search(body.Query, body.MaxResults)})
> }
>
> func main() {
> 	http.HandleFunc("/search", handler)
> 	log.Fatal(http.ListenAndServe(":8080", nil))
> }
> ```

> [!NOTE]
>
> The samples show the structure and have not been run against a live 60db agent. Put them behind HTTPS with a valid certificate; the platform refuses plain `http`.

### What 60db does with the text

Your text goes into the model's prompt as reference data, never as instructions. Angle brackets are removed, long passages are cut, and the block is framed so the model does not follow anything written inside it. **Keep your own documents clean anyway.** Text copied from web pages or customer messages can try to steer a model (prompt injection), and the framing reduces that risk but cannot remove it.

## What is billed

* **Collection searches** are billed as Memory searches (\$0.0003 per query), from your AI credits. Ingesting is billed per character. See [Memory pricing](https://docs.60db.ai/api-reference/memory/pricing.md).
* **Webhook searches** are not billed by 60db. Your own infrastructure costs are yours.

## Where it applies

* **Phone calls** (inbound and outbound) use the agent's knowledge source.
* **Stream calls** started from an agent use the agent's knowledge source, searched the same way.
* **Calls placed with an inline agent definition** (`POST /agents/calls`) take `knowledge_collection_id` in that definition. They do not take `knowledge_webhook_url`: to search your own server, store the agent and call it by id.
* **Web (browser) calls** started from an agent use the agent's saved knowledge source. Per-call overrides on web calls do not include knowledge fields: change the agent instead.

## Checklist

* [ ] Exactly one of `knowledge_collection_id` or `knowledge_webhook_url` is set.
* [ ] Collection: documents ingested as `type: "knowledge"`, and the collection is a workspace one.
* [ ] Webhook: answers within a second or so, over HTTPS with a valid certificate.
* [ ] Webhook: signature and timestamp checked with the agent's `whsec_...` secret.
* [ ] A test call answers something only your documents cover.
* [ ] Your logs show about one search per turn. 150 per call is a ceiling, not a target.

## Related

* [Agents](https://docs.60db.ai/features/agents.md) and [Tools and webhooks](https://docs.60db.ai/features/agents-tools-and-webhooks.md)
* [Memory](https://docs.60db.ai/features/memory.md), [Collections](https://docs.60db.ai/api-reference/memory/collections.md), [Ingest](https://docs.60db.ai/api-reference/memory/ingest.md)
* [Names: HelloKit and 60db](https://docs.60db.ai/features/agents-glossary.md)
* [Agents MCP tools](https://docs.60db.ai/mcp-server/agents.md) and [Memory MCP tools](https://docs.60db.ai/mcp-server/memory.md)


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