Skip to main content

Agent over a Library of PDFs

Upload PDFs, ingest them into chunked, embedded Documents, scope an agent to them with knowledge_config, and get answers from the right page with no RAG logic in the prompt.

Plan stepWhere in this tutorial
A. Ingest the PDFs (organize with a path prefix)Steps 5–7
B. Create the agent scoped with knowledge_configStep 9
C. Retrieval — automatic, agent-driven via API, and agent-driven via toolSteps 10–12
D. Citations — page + document_id per resultStep 8

Prerequisites​

  • SOAT running locally (Quick Start); Key Concepts.
  • CLI or SDK; server at http://localhost:5047.
  • Ollama with qwen2.5:0.5b pulled. The PDFs are tiny so a small model answers reliably from the injected context.

Step 1 — Log in as admin​

See Users for authentication.

ADMIN_TOKEN=$(soat login-user --username admin --password Admin1234! | jq -r '.token')
export SOAT_TOKEN=$ADMIN_TOKEN

Step 2 — Create a project​

Every resource lives inside a project.

PROJECT_ID=$(soat create-project --name "Manuals Demo" | jq -r '.id')
echo "PROJECT_ID: $PROJECT_ID"

Step 3 — Create an AI provider​

A local Ollama AI provider. For other providers see Connect Third-Party LLMs.

AI_PROVIDER_ID=$(soat create-ai-provider \
--project-id "$PROJECT_ID" \
--name "Local Ollama" \
--provider "ollama" \
--default-model "qwen2.5:0.5b" | jq -r '.id')
echo "AI_PROVIDER_ID: $AI_PROVIDER_ID"

Step 4 — Prepare two small PDFs​

Two base64 single-page PDFs with a text layer unpdf (the server's parser) extracts. Their facts:

  • printer-x1000.pdf — "The paper tray holds 250 sheets.", standby timeout 5 minutes, toner every 8000 pages.
  • router-r200.pdf — "The default admin password is admin1234.", up to 32 devices, 10-second reset.
PRINTER_PDF_B64="JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA0IDAgUiA+PiA+PiAvQ29udGVudHMgNSAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL1R5cGUgL0ZvbnQgL1N1YnR5cGUgL1R5cGUxIC9CYXNlRm9udCAvSGVsdmV0aWNhID4+CmVuZG9iago1IDAgb2JqCjw8IC9MZW5ndGggMjA4ID4+CnN0cmVhbQpCVCAvRjEgMTYgVGYgNzIgNzIwIFRkIDIwIFRMCihYMTAwMCBQcmludGVyIFF1aWNrIEd1aWRlKSBUagpUKiAoVGhlIHBhcGVyIHRyYXkgaG9sZHMgMjUwIHNoZWV0cy4pIFRqClQqIChUaGUgZGVmYXVsdCBzdGFuZGJ5IHRpbWVvdXQgaXMgNSBtaW51dGVzLikgVGoKVCogKFJlcGxhY2UgdGhlIHRvbmVyIGNhcnRyaWRnZSBldmVyeSA4MDAwIHBhZ2VzLikgVGoKRVQKCmVuZHN0cmVhbQplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzMTEgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo1NzAKJSVFT0Y="

ROUTER_PDF_B64="JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA0IDAgUiA+PiA+PiAvQ29udGVudHMgNSAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL1R5cGUgL0ZvbnQgL1N1YnR5cGUgL1R5cGUxIC9CYXNlRm9udCAvSGVsdmV0aWNhID4+CmVuZG9iago1IDAgb2JqCjw8IC9MZW5ndGggMjI5ID4+CnN0cmVhbQpCVCAvRjEgMTYgVGYgNzIgNzIwIFRkIDIwIFRMCihSMjAwIFJvdXRlciBTZXR1cCBHdWlkZSkgVGoKVCogKFRoZSBkZWZhdWx0IGFkbWluIHBhc3N3b3JkIGlzIGFkbWluMTIzNC4pIFRqClQqIChUaGUgcm91dGVyIHN1cHBvcnRzIHVwIHRvIDMyIGNvbm5lY3RlZCBkZXZpY2VzLikgVGoKVCogKEhvbGQgdGhlIHJlc2V0IGJ1dHRvbiBmb3IgMTAgc2Vjb25kcyB0byBmYWN0b3J5IHJlc2V0LikgVGoKRVQKCmVuZHN0cmVhbQplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzMTEgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo1OTEKJSVFT0Y="

Step 5 — Upload the PDFs​

Upload each PDF as a File with content_type application/pdf; ingestion dispatches on it.

PRINTER_FILE_ID=$(soat upload-file-base64 \
--project-id "$PROJECT_ID" \
--filename "printer-x1000.pdf" \
--content-type "application/pdf" \
--content "$PRINTER_PDF_B64" | jq -r '.id')
echo "PRINTER_FILE_ID: $PRINTER_FILE_ID"

ROUTER_FILE_ID=$(soat upload-file-base64 \
--project-id "$PROJECT_ID" \
--filename "router-r200.pdf" \
--content-type "application/pdf" \
--content "$ROUTER_PDF_B64" | jq -r '.id')
echo "ROUTER_FILE_ID: $ROUTER_FILE_ID"

Step 6 — Ingest the PDFs (Plan A)​

POST /documents/ingest extracts text page-by-page, chunks and embeds it, and stores one Document with many DocumentChunk rows. --path-prefix puts the documents under a common path so an agent can be scoped to the subtree with one document_paths prefix.

Ingestion is asynchronous (Documents — Async File Ingestion); --wait true blocks until the document is ready. chunk_count comes from GET /documents/:id/status. The default page strategy yields one chunk per page, so chunk_count is 1 here.

PRINTER_DOC_ID=$(soat ingest-document \
--project-id "$PROJECT_ID" \
--file-id "$PRINTER_FILE_ID" \
--path-prefix "/manuals/" \
--wait true | jq -r '.id')
soat get-document-status --document-id "$PRINTER_DOC_ID" \
| jq '{id: .id, status: .status, chunk_count: .chunk_count}'
# → { "id": "doc_...", "status": "ready", "chunk_count": 1 }

ROUTER_DOC_ID=$(soat ingest-document \
--project-id "$PROJECT_ID" \
--file-id "$ROUTER_FILE_ID" \
--path-prefix "/manuals/" \
--wait true | jq -r '.id')
soat get-document-status --document-id "$ROUTER_DOC_ID" \
| jq '{id: .id, status: .status, chunk_count: .chunk_count}'
# → { "id": "doc_...", "status": "ready", "chunk_count": 1 }

Step 7 — Finer chunks with the size strategy (Plan A, optional)​

The size strategy splits extracted text into fixed-size character windows (chunk_size / chunk_overlap) for sharper retrieval on dense pages. size chunks are not page-aligned and carry no page number.

A file backs only one Document: re-ingesting $PRINTER_FILE_ID returns 409 FILE_ALREADY_INGESTED. Upload a second copy of the bytes and ingest that file with small windows:

PRINTER_FILE_ID_SIZE=$(soat upload-file-base64 \
--project-id "$PROJECT_ID" \
--filename "printer-x1000.pdf" \
--content-type "application/pdf" \
--content "$PRINTER_PDF_B64" | jq -r '.id')

SIZED_DOC_ID=$(soat ingest-document \
--project-id "$PROJECT_ID" \
--file-id "$PRINTER_FILE_ID_SIZE" \
--path-prefix "/manuals-size/" \
--chunk-strategy "size" \
--chunk-size 60 \
--chunk-overlap 10 \
--wait true | jq -r '.id')
soat get-document-status --document-id "$SIZED_DOC_ID" \
| jq '{id: .id, status: .status, chunk_count: .chunk_count}'
# → { "id": "doc_...", "status": "ready", "chunk_count": 3 } # multiple windows from one page

Prefer page (citations); switch to size when recall is poor on dense documents.


Step 8 — Search the knowledge layer directly (Plan D)​

Search runs at the chunk level: each result carries document_id, chunk_id and, for page-chunked docs, page. Scope to /manuals/ with document_paths. See Knowledge.

soat search-knowledge \
--project-id "$PROJECT_ID" \
--query "How many sheets does the paper tray hold?" \
--document-paths '["/manuals/"]' \
--limit 3 \
| jq '[.results[] | {document_id, chunk_id, page, similarity_score, content}]'

The top hit is the printer chunk:

[
{
"document_id": "doc_...",
"chunk_id": "dchunk_...",
"page": 1,
"similarity_score": 0.78,
"content": "X1000 Printer Quick Guide\nThe paper tray holds 250 sheets. ..."
}
]

document_id + page let an agent cite "per printer-x1000.pdf, page 1".


Step 9 — Create the agent scoped to the PDFs (Plan B)​

knowledge_config searches the manuals before every generation with the last user message as the query. Scope to /manuals/; bound results with min_score and limit. See Agents.

AGENT_ID=$(soat create-agent \
--project-id "$PROJECT_ID" \
--ai-provider-id "$AI_PROVIDER_ID" \
--name "Manuals Agent" \
--instructions "You are a product support assistant. Answer using only the provided knowledge context. Be concise and cite the document and page when possible." \
--knowledge-config '{"document_paths":["/manuals/"],"min_score":0.5,"limit":8}' \
| jq -r '.id')
echo "AGENT_ID: $AGENT_ID"

Step 10 — Automatic retrieval (Plan C)​

SOAT embeds the user message, searches /manuals/, and injects the top chunks as a system message before the model runs (Agents).

soat create-agent-generation --wait true \
--agent-id "$AGENT_ID" \
--messages '[{"role":"user","content":"What is the default admin password for the R200 router?"}]' \
| jq '{status: .status, output: .output.content}'

Expected shape (exact wording varies by model):

{
"status": "completed",
"output": "The default admin password for the R200 router is admin1234."
}

admin1234 appears only in router-r200.pdf.


Step 11 — Agent-driven retrieval (Plan C)​

For a reformulated query, call the same search-knowledge operation explicitly (Knowledge); this is what an agent does when it decides to look something up.

soat search-knowledge \
--project-id "$PROJECT_ID" \
--query "printer paper tray capacity sheets" \
--document-paths '["/manuals/"]' \
--limit 1 \
| jq '.results[0] | {document_id, page, content}'
# → the X1000 chunk: "...The paper tray holds 250 sheets..."

Automatic retrieval suits single-shot Q&A; agent-driven retrieval suits questions the agent must break down.


Step 12 — Give the agent a knowledge tool (Plan D)​

Wrap the operation as a builtin tool so the model decides when to search and what to ask. preset_parameters pins the project and the /manuals/ subtree, hidden from the model; only query (and optionally limit) remain.

KNOWLEDGE_TOOL_ID=$(soat create-tool \
--project-id "$PROJECT_ID" \
--name "manuals" \
--type builtin \
--description "Searches the ingested product manuals for relevant passages" \
--actions '["search-knowledge"]' \
--preset-parameters '{"project_id": "'"$PROJECT_ID"'", "document_paths": ["/manuals/"]}' \
| jq -r '.id')
echo "KNOWLEDGE_TOOL_ID: $KNOWLEDGE_TOOL_ID"

Attach it to a new agent without knowledge_config, so the tool is the only path to the manuals. The model sees it as manuals_search-knowledge (Tool Name Resolution):

TOOL_AGENT_ID=$(soat create-agent \
--project-id "$PROJECT_ID" \
--ai-provider-id "$AI_PROVIDER_ID" \
--name "Manuals Agent (Tool-Driven)" \
--instructions "You are a product support assistant. Always call the manuals_search-knowledge tool with a short search query before answering a product question. Be concise and cite the document and page when possible." \
--tool-bindings '[{"tool_id": "'"$KNOWLEDGE_TOOL_ID"'"}]' \
| jq -r '.id')
echo "TOOL_AGENT_ID: $TOOL_AGENT_ID"

The model calls manuals_search-knowledge, the server executes it in-process, and the response comes back completed; builtin tools run server-side, unlike client tools, which pause with requires_action:

soat create-agent-generation --wait true \
--agent-id "$TOOL_AGENT_ID" \
--messages '[{"role":"user","content":"How many devices can the R200 router support?"}]' \
| jq '{status: .status, output: .output.content}'

Expected shape (exact wording varies by model):

{
"status": "completed",
"output": "The R200 router supports up to 32 connected devices."
}

An agent can carry both knowledge_config and a builtin knowledge tool: automatic context every turn plus a self-written follow-up query.


What you built​

Ingest more PDFs under /manuals/ and the agent picks them up with no config change. For larger sets, use nested prefixes (/manuals/network/, /manuals/print/) and point different agents at different subtrees.