Split a library between agents
By the end of this tutorial you will have two agents that each answer only from their own slice of a shared knowledge base — a billing agent grounded in a billing collection, a shipping agent grounded in a shipping collection, and no overlap between what either one can see.
Six steps:
- Create a collection per topic.
- Add a document to each.
- Confirm they're indexed.
- Give each collection its own search tool.
- Create an agent per tool.
- Ask each agent a question only its own topic can answer.
Every step is one API call, shown for all three clients. The ids in the responses are examples — copy the ones your own calls return.
Prerequisites
-
A credential. A
nat_sk_…API key (or a session JWT from Auth) exported asNATURALI_TOKEN, and your client set up — the CLI, the SDK or plaincurl:export NATURALI_TOKEN=nat_sk_...export NATURALI_API=https://api.naturali.ai/v1 # curl and tool-config examples -
A project with a working provider in it. Create a provider builds one — do it first if you haven't, and arrive with both ids exported:
export PROJECT=proj_V1StGXR8Z5jdHi6Bexport PROVIDER=aip_V1StGXR8Z5jdHi6Bexport NATURALI_PROJECT=$PROJECT # lets the CLI omit --project-id
This tutorial uses inline text instead of uploaded files, so there is nothing else to have on hand. For chunking a large PDF library instead, see Search a library of PDFs — the collection and document calls there are the same ones used below.
1. Create a collection per topic
A collection is the unit retrieval is scoped to, so a "folder" in this library is one collection per topic — not a field inside a single collection. Two collections keep billing passages from ever surfacing on a shipping question, and vice versa.
- CLI
- SDK
- curl
naturali create-knowledge-collection \
--project-id "$PROJECT" \
--name billing \
--description 'Refunds, charges and invoices.'
naturali create-knowledge-collection \
--project-id "$PROJECT" \
--name shipping \
--description 'Delivery times and carriers.'
const { data: billing } = await naturali.knowledge.createKnowledgeCollection({
path: { project_id: PROJECT },
body: { name: 'billing', description: 'Refunds, charges and invoices.' },
});
const { data: shipping } = await naturali.knowledge.createKnowledgeCollection({
path: { project_id: PROJECT },
body: { name: 'shipping', description: 'Delivery times and carriers.' },
});
curl -sS -X POST "$NATURALI_API/projects/$PROJECT/knowledge/collections" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H 'Content-Type: application/json' \
-d '{ "name": "billing", "description": "Refunds, charges and invoices." }'
curl -sS -X POST "$NATURALI_API/projects/$PROJECT/knowledge/collections" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H 'Content-Type: application/json' \
-d '{ "name": "shipping", "description": "Delivery times and carriers." }'
{
"id": "kcol_V1StGXR8Z5jdHi6B",
"project_id": "proj_V1StGXR8Z5jdHi6B",
"name": "billing",
"document_count": 0
}
Export both ids — every later step needs them:
export COLLECTION_BILLING=kcol_V1StGXR8Z5jdHi6B
export COLLECTION_SHIPPING=kcol_V1StGXR8Z5jdHi6B # a different id than billing's
2. Add a document to each
A document can be
inline content instead of an uploaded file — the simplest way to seed a
topic with a policy paragraph.
- CLI
- SDK
- curl
naturali create-knowledge-document \
--project-id "$PROJECT" \
--collection-id "$COLLECTION_BILLING" \
--title 'Refund policy' \
--content 'Refunds are issued once a return is received. Store credit posts immediately; card refunds take 5 business days to appear.'
naturali create-knowledge-document \
--project-id "$PROJECT" \
--collection-id "$COLLECTION_SHIPPING" \
--title 'Shipping policy' \
--content 'Standard shipping takes 3-7 business days. Expedited shipping arrives in 1-2 business days for an extra $15.'
const { data: billingDoc } = await naturali.knowledge.createKnowledgeDocument({
path: { project_id: PROJECT, collection_id: COLLECTION_BILLING },
body: {
title: 'Refund policy',
content:
'Refunds are issued once a return is received. Store credit posts immediately; card refunds take 5 business days to appear.',
},
});
const { data: shippingDoc } = await naturali.knowledge.createKnowledgeDocument(
{
path: { project_id: PROJECT, collection_id: COLLECTION_SHIPPING },
body: {
title: 'Shipping policy',
content:
'Standard shipping takes 3-7 business days. Expedited shipping arrives in 1-2 business days for an extra $15.',
},
},
);
curl -sS -X POST \
"$NATURALI_API/projects/$PROJECT/knowledge/collections/$COLLECTION_BILLING/documents" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"title": "Refund policy",
"content": "Refunds are issued once a return is received. Store credit posts immediately; card refunds take 5 business days to appear."
}'
curl -sS -X POST \
"$NATURALI_API/projects/$PROJECT/knowledge/collections/$COLLECTION_SHIPPING/documents" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"title": "Shipping policy",
"content": "Standard shipping takes 3-7 business days. Expedited shipping arrives in 1-2 business days for an extra $15."
}'
{
"id": "doc_V1StGXR8Z5jdHi6B",
"collection_id": "kcol_V1StGXR8Z5jdHi6B",
"filename": "Refund policy",
"status": "indexed",
"chunk_count": null
}
filename carries the title you sent — a document has no separate title
field, so this doubles as its label. A short inline paragraph like this one
typically finishes ingestion inside the request itself, which is why status
already reads indexed here instead of pending.
export DOCUMENT_BILLING=doc_V1StGXR8Z5jdHi6B
export DOCUMENT_SHIPPING=doc_V1StGXR8Z5jdHi6B # a different id than billing's
3. Confirm they're indexed
A large upload ingests in the background (see
step 3 of the PDF tutorial
for polling a pending document to indexed). These two are already
indexed from step 2, so reading either one back is just confirmation, not a
wait:
- CLI
- SDK
- curl
naturali get-knowledge-document \
--project-id "$PROJECT" \
--collection-id "$COLLECTION_BILLING" \
--document-id "$DOCUMENT_BILLING"
const { data: indexed } = await naturali.knowledge.getKnowledgeDocument({
path: {
project_id: PROJECT,
collection_id: COLLECTION_BILLING,
document_id: DOCUMENT_BILLING,
},
});
console.log(indexed.status, indexed.chunk_count);
curl -sS \
"$NATURALI_API/projects/$PROJECT/knowledge/collections/$COLLECTION_BILLING/documents/$DOCUMENT_BILLING" \
-H "Authorization: Bearer $NATURALI_TOKEN"
{ "id": "doc_V1StGXR8Z5jdHi6B", "status": "indexed", "chunk_count": null }
Repeat for the shipping document. If either ever comes back pending —
larger content, or a converter in the path — poll it, or subscribe a
webhook to knowledge.document_ingested instead.
4. Give each collection its own search tool
An agent never binds to a collection directly — it calls a
tool, and a tool can be any HTTP endpoint,
including the platform's own
:query endpoint. An http tool
runs server-side, so an agent that calls it never leaves completed for
requires_action the way a client-executed tool would.
Point one tool's execute.url at the billing collection and the other's at
shipping — that fixed URL, baked in at creation time, is what actually scopes
each tool to its topic. The model only ever supplies query (and optionally
top_k); it never sees or chooses the collection.
- CLI
- SDK
- curl
naturali create-tool \
--project-id "$PROJECT" \
--name search-billing \
--type http \
--description 'Search the billing knowledge base for refund, charge and invoice questions.' \
--execute '{"url":"'"$NATURALI_API"'/projects/'"$PROJECT"'/knowledge/collections/'"$COLLECTION_BILLING"':query","method":"POST","headers":{"Authorization":"Bearer '"$NATURALI_TOKEN"'"}}' \
--parameters '{"type":"object","properties":{"query":{"type":"string"},"top_k":{"type":"integer"}},"required":["query"]}'
naturali create-tool \
--project-id "$PROJECT" \
--name search-shipping \
--type http \
--description 'Search the shipping knowledge base for delivery and carrier questions.' \
--execute '{"url":"'"$NATURALI_API"'/projects/'"$PROJECT"'/knowledge/collections/'"$COLLECTION_SHIPPING"':query","method":"POST","headers":{"Authorization":"Bearer '"$NATURALI_TOKEN"'"}}' \
--parameters '{"type":"object","properties":{"query":{"type":"string"},"top_k":{"type":"integer"}},"required":["query"]}'
const { data: searchBilling } = await naturali.tools.createTool({
path: { project_id: PROJECT },
body: {
name: 'search-billing',
type: 'http',
description:
'Search the billing knowledge base for refund, charge and invoice questions.',
execute: {
url: `${process.env.NATURALI_API}/projects/${PROJECT}/knowledge/collections/${COLLECTION_BILLING}:query`,
method: 'POST',
headers: { Authorization: `Bearer ${process.env.NATURALI_TOKEN}` },
},
parameters: {
type: 'object',
properties: { query: { type: 'string' }, top_k: { type: 'integer' } },
required: ['query'],
},
},
});
const { data: searchShipping } = await naturali.tools.createTool({
path: { project_id: PROJECT },
body: {
name: 'search-shipping',
type: 'http',
description:
'Search the shipping knowledge base for delivery and carrier questions.',
execute: {
url: `${process.env.NATURALI_API}/projects/${PROJECT}/knowledge/collections/${COLLECTION_SHIPPING}:query`,
method: 'POST',
headers: { Authorization: `Bearer ${process.env.NATURALI_TOKEN}` },
},
parameters: {
type: 'object',
properties: { query: { type: 'string' }, top_k: { type: 'integer' } },
required: ['query'],
},
},
});
curl -sS -X POST "$NATURALI_API/projects/$PROJECT/tools" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H 'Content-Type: application/json' \
-d "{
\"name\": \"search-billing\",
\"type\": \"http\",
\"description\": \"Search the billing knowledge base for refund, charge and invoice questions.\",
\"execute\": {
\"url\": \"$NATURALI_API/projects/$PROJECT/knowledge/collections/$COLLECTION_BILLING:query\",
\"method\": \"POST\",
\"headers\": { \"Authorization\": \"Bearer $NATURALI_TOKEN\" }
},
\"parameters\": {
\"type\": \"object\",
\"properties\": { \"query\": { \"type\": \"string\" }, \"top_k\": { \"type\": \"integer\" } },
\"required\": [\"query\"]
}
}"
curl -sS -X POST "$NATURALI_API/projects/$PROJECT/tools" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H 'Content-Type: application/json' \
-d "{
\"name\": \"search-shipping\",
\"type\": \"http\",
\"description\": \"Search the shipping knowledge base for delivery and carrier questions.\",
\"execute\": {
\"url\": \"$NATURALI_API/projects/$PROJECT/knowledge/collections/$COLLECTION_SHIPPING:query\",
\"method\": \"POST\",
\"headers\": { \"Authorization\": \"Bearer $NATURALI_TOKEN\" }
},
\"parameters\": {
\"type\": \"object\",
\"properties\": { \"query\": { \"type\": \"string\" }, \"top_k\": { \"type\": \"integer\" } },
\"required\": [\"query\"]
}
}"
{
"id": "tool_V1StGXR8Z5jdHi6B",
"project_id": "proj_V1StGXR8Z5jdHi6B",
"name": "search-billing",
"type": "http",
"has_headers": true,
"status": "active"
}
export SEARCH_BILLING_TOOL=tool_V1StGXR8Z5jdHi6B
export SEARCH_SHIPPING_TOOL=tool_V1StGXR8Z5jdHi6B # a different id than billing's
has_headers: true is all a later read of the tool ever shows — the
Authorization value itself is accepted on write and never returned. There is
nothing to redact by hand later; the platform already never gives it back.
The raw :query response — chunks with content and score — is already
something a model can read directly, so this tutorial leaves output_mapping
unset. Reduce it if you want the model to see less: see
Tools.
5. Create an agent per tool
Each agent gets exactly one search tool in its
tool_bindings, and instructions that tell it what to do when that tool comes back
empty — the same knowledge base has an answer for the other agent's question,
but this agent has no way to reach it.
Not every model behind a managed provider reaches for a tool as readily as
the others — a smaller model can describe calling the tool in prose instead of
actually invoking it, which silently defeats the grounding this tutorial is
built on. Set model on the agent to one you've confirmed calls tools well —
deepseek.v3.2 did so reliably in testing — rather than trusting whatever the
provider's own default happens to be. Check the
model catalog for what's available on your provider.
- CLI
- SDK
- curl
naturali create-agent \
--project-id "$PROJECT" \
--provider-id "$PROVIDER" \
--name billing-agent \
--model deepseek.v3.2 \
--instructions 'Answer billing questions using the search-billing tool. If it returns nothing relevant, say you do not have that information.' \
--tool-bindings '[{"tool_id":"'"$SEARCH_BILLING_TOOL"'"}]'
naturali create-agent \
--project-id "$PROJECT" \
--provider-id "$PROVIDER" \
--name shipping-agent \
--model deepseek.v3.2 \
--instructions 'Answer shipping questions using the search-shipping tool. If it returns nothing relevant, say you do not have that information.' \
--tool-bindings '[{"tool_id":"'"$SEARCH_SHIPPING_TOOL"'"}]'
const { data: billingAgent } = await naturali.agents.createAgent({
path: { project_id: PROJECT },
body: {
provider_id: PROVIDER,
name: 'billing-agent',
model: 'deepseek.v3.2',
instructions:
'Answer billing questions using the search-billing tool. If it returns nothing relevant, say you do not have that information.',
tool_bindings: [{ tool_id: SEARCH_BILLING_TOOL }],
},
});
const { data: shippingAgent } = await naturali.agents.createAgent({
path: { project_id: PROJECT },
body: {
provider_id: PROVIDER,
name: 'shipping-agent',
model: 'deepseek.v3.2',
instructions:
'Answer shipping questions using the search-shipping tool. If it returns nothing relevant, say you do not have that information.',
tool_bindings: [{ tool_id: SEARCH_SHIPPING_TOOL }],
},
});
curl -sS -X POST "$NATURALI_API/projects/$PROJECT/agents" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"provider_id": "'"$PROVIDER"'",
"name": "billing-agent",
"model": "deepseek.v3.2",
"instructions": "Answer billing questions using the search-billing tool. If it returns nothing relevant, say you do not have that information.",
"tool_bindings": [{ "tool_id": "'"$SEARCH_BILLING_TOOL"'" }]
}'
curl -sS -X POST "$NATURALI_API/projects/$PROJECT/agents" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"provider_id": "'"$PROVIDER"'",
"name": "shipping-agent",
"model": "deepseek.v3.2",
"instructions": "Answer shipping questions using the search-shipping tool. If it returns nothing relevant, say you do not have that information.",
"tool_bindings": [{ "tool_id": "'"$SEARCH_SHIPPING_TOOL"'" }]
}'
{
"id": "agent_V1StGXR8Z5jdHi6B",
"project_id": "proj_V1StGXR8Z5jdHi6B",
"name": "billing-agent",
"model": "deepseek.v3.2",
"tool_bindings": [{ "tool_id": "tool_V1StGXR8Z5jdHi6B" }],
"status": "active"
}
export BILLING_AGENT=agent_V1StGXR8Z5jdHi6B
export SHIPPING_AGENT=agent_V1StGXR8Z5jdHi6B # a different id than billing's
6. Ask each agent a question only its own topic can answer
Ask the billing agent about refunds, then about shipping. The first answer comes from the passage you indexed in step 2; the second proves the split — the agent has no tool that can reach the shipping collection, so it says so instead of guessing.
- CLI
- SDK
- curl
naturali create-generation \
--agent-id "$BILLING_AGENT" \
--action-id tutorial.split-library \
--messages '[{"role":"user","content":"How long do card refunds take to appear?"}]'
const { data: refundAnswer } = await naturali.generations.createGeneration({
path: { project_id: PROJECT, agent_id: BILLING_AGENT },
body: {
action_id: 'tutorial.split-library',
messages: [
{ role: 'user', content: 'How long do card refunds take to appear?' },
],
},
});
console.log(refundAnswer?.text);
// 'Card refunds typically take 5 business days to appear once your return is received.'
curl -sS -X POST \
"$NATURALI_API/projects/$PROJECT/agents/$BILLING_AGENT/generations" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"action_id": "tutorial.split-library",
"messages": [
{ "role": "user", "content": "How long do card refunds take to appear?" }
]
}'
{
"status": "completed",
"text": "Card refunds typically take 5 business days to appear once your return is received."
}
Now ask the same agent about shipping:
- CLI
- SDK
- curl
naturali create-generation \
--agent-id "$BILLING_AGENT" \
--action-id tutorial.split-library \
--messages '[{"role":"user","content":"How long does expedited shipping take?"}]'
const { data: shippingAnswer } = await naturali.generations.createGeneration({
path: { project_id: PROJECT, agent_id: BILLING_AGENT },
body: {
action_id: 'tutorial.split-library',
messages: [
{ role: 'user', content: 'How long does expedited shipping take?' },
],
},
});
console.log(shippingAnswer?.text);
// "I don't have that specific shipping information — my knowledge base covers billing questions only."
curl -sS -X POST \
"$NATURALI_API/projects/$PROJECT/agents/$BILLING_AGENT/generations" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"action_id": "tutorial.split-library",
"messages": [
{ "role": "user", "content": "How long does expedited shipping take?" }
]
}'
{
"status": "completed",
"text": "I don't have that specific shipping information — my knowledge base covers billing questions only."
}
That is the split working: the same question put to $SHIPPING_AGENT instead
gets the "1-2 business days" answer from its own collection. Add a third topic
by repeating steps 1, 2, 4 and 5 with a new name — collection, tool, agent —
and the library grows without any existing agent gaining access to it.
What's next
- Traces — see the
search-billingtool call itself, not just its effect on the answer. - Search a library of PDFs — chunking strategies for when a topic's documents are longer than one paragraph.
- Ingest images and audio — bring scanned receipts and recordings into either collection.
- Tools —
output_mapping,preset_parametersand MCP tools, beyond the plainhttpsearch tool built here.