Ingest images and audio
By the end of this tutorial you will have a collection that ingests images and audio as readily as it ingests PDFs — a receipt photo and a meeting recording, both retrievable by question.
The platform reads PDFs, text and markdown natively. Everything else needs a converter: a rule that claims a media type and names who turns a file of that type into text. There are two kinds, and this tutorial builds one of each, on the job it suits:
| Converter | Claims | Because |
|---|---|---|
| Agent | image/* | A multimodal model reads an image directly. Nothing to map — you point at an agent and you are done. |
| Tool | audio/* | Speech-to-text APIs are not chat endpoints, so no model can call one. An http tool calls it directly. |
Five steps:
- Create the OCR agent.
- Route images to it.
- Ingest an image.
- (Optional) OCR scanned PDFs too.
- Route audio to a transcription tool.
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, exported as
NATURALI_TOKEN, with your client set up — the CLI, the SDK or plaincurl:export NATURALI_TOKEN=nat_sk_...export NATURALI_API=https://api.naturali.ai/v1 # curl examples only -
A project with a provider whose model can see images. Any vision-capable model in the catalog will do. Create a provider builds one; arrive with both ids exported:
export PROJECT=proj_V1StGXR8Z5jdHi6Bexport PROVIDER=aip_V1StGXR8Z5jdHi6Bexport NATURALI_PROJECT=$PROJECT # lets the CLI omit --project-id -
A collection to ingest into. Search a library of PDFs builds one and teaches the upload call this tutorial reuses:
export COLLECTION=kcol_V1StGXR8Z5jdHi6B -
A
receipt.pngin the working directory — any photo or screenshot with legible text — and, for step 5, a shortmeeting.mp3.
1. Create the OCR agent
An agent converter is just an agent. The instructions carry all the weight: the model must transcribe, not summarize, because its answer becomes the document text verbatim.
- CLI
- SDK
- curl
naturali create-agent \
--project-id "$PROJECT" \
--provider-id "$PROVIDER" \
--name ocr \
--instructions 'Extract all text from the provided file verbatim. Return plain text only — no commentary, no summary, no markdown fences.'
const { data: ocrAgent } = await naturali.agents.createAgent({
path: { project_id: PROJECT },
body: {
provider_id: PROVIDER,
name: 'ocr',
instructions:
'Extract all text from the provided file verbatim. Return plain text only — no commentary, no summary, no markdown fences.',
},
});
curl -sS -X POST "$NATURALI_API/projects/$PROJECT/agents" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"provider_id": "aip_V1StGXR8Z5jdHi6B",
"name": "ocr",
"instructions": "Extract all text from the provided file verbatim. Return plain text only — no commentary, no summary, no markdown fences."
}'
{
"id": "agent_V1StGXR8Z5jdHi6B",
"project_id": "proj_V1StGXR8Z5jdHi6B",
"name": "ocr",
"status": "active"
}
export OCR_AGENT=agent_V1StGXR8Z5jdHi6B
2. Route images to it
The converter claims image/* and names the agent. whole chunking keeps a
short OCR result as one passage rather than splitting a receipt into fragments.
- CLI
- SDK
- curl
naturali create-knowledge-converter \
--project-id "$PROJECT" \
--content-type 'image/*' \
--agent-id "$OCR_AGENT" \
--chunk-strategy whole
const { data: imageConverter } =
await naturali.knowledge.createKnowledgeConverter({
path: { project_id: PROJECT },
body: {
content_type: 'image/*',
agent_id: OCR_AGENT,
chunk_strategy: 'whole',
},
});
curl -sS -X POST "$NATURALI_API/projects/$PROJECT/knowledge/converters" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"content_type": "image/*",
"agent_id": "agent_V1StGXR8Z5jdHi6B",
"chunk_strategy": "whole"
}'
{
"id": "igr_V1StGXR8Z5jdHi6B",
"project_id": "proj_V1StGXR8Z5jdHi6B",
"content_type": "image/*",
"agent_id": "agent_V1StGXR8Z5jdHi6B",
"tool_id": null,
"native_extraction": "first",
"chunk_strategy": "whole"
}
A media type belongs to one converter: a second one claiming image/* is a
409 converter_content_type_taken, because two rules for the same type would
make routing a coin toss.
3. Ingest an image
The upload call is the one from
step 2 of the PDF tutorial,
unchanged. Nothing here names the converter or the agent — content_type: image/png is what routes the file, and the converter is resolved from it.
- CLI
- SDK
- curl
naturali create-knowledge-document \
--project-id "$PROJECT" \
--collection-id "$COLLECTION" \
--filename receipt.png \
--content-type image/png \
--file "$(base64 -w0 receipt.png)"
import { readFileSync } from 'node:fs';
const { data: image } = await naturali.knowledge.createKnowledgeDocument({
path: { project_id: PROJECT, collection_id: COLLECTION },
body: {
filename: 'receipt.png',
content_type: 'image/png',
file: readFileSync('receipt.png').toString('base64'),
},
});
curl -sS -X POST \
"$NATURALI_API/projects/$PROJECT/knowledge/collections/$COLLECTION/documents" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H 'Content-Type: application/json' \
-d "{
\"filename\": \"receipt.png\",
\"content_type\": \"image/png\",
\"file\": \"$(base64 -w0 receipt.png)\"
}"
{
"id": "doc_V1StGXR8Z5jdHi6B",
"filename": "receipt.png",
"content_type": "image/png",
"status": "pending",
"error": null
}
Read the document until it is indexed (step 3 of the PDF tutorial), then
query the collection for something only the image says — the OCR'd text is
indexed like any other passage:
- CLI
- SDK
- curl
naturali query-knowledge-collection \
--project-id "$PROJECT" \
--collection-id "$COLLECTION" \
--query 'total amount on the receipt' \
--top-k 3
const { data: hits } = await naturali.knowledge.queryKnowledgeCollection({
path: { project_id: PROJECT, collection_id: COLLECTION },
body: { query: 'total amount on the receipt', top_k: 3 },
});
for (const chunk of hits.data) console.log(chunk.score, chunk.content);
curl -sS -X POST \
"$NATURALI_API/projects/$PROJECT/knowledge/collections/$COLLECTION:query" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H 'Content-Type: application/json' \
-d '{ "query": "total amount on the receipt", "top_k": 3 }'
status: failed with error: "CONVERTER_FAILED" usually means the model
returned a non-answer rather than the text — the ordinary intermittency of any
LLM call. Retry with
POST /v1/projects/{project_id}/knowledge/collections/{collection_id}/documents/{document_id}:reingest.
If it fails every time, the model behind your provider probably cannot see
images.
4. (Optional) OCR scanned PDFs too
A scanned PDF is application/pdf with no text layer, so native extraction
finds nothing. Point a second converter at the same agent and it becomes the
fallback for exactly those files — because a converter on a natively-readable
type is consulted only when extraction comes up empty, born-digital PDFs
keep taking the fast path and cost nothing extra.
- CLI
- SDK
- curl
naturali create-knowledge-converter \
--project-id "$PROJECT" \
--content-type application/pdf \
--agent-id "$OCR_AGENT" \
--chunk-strategy whole
await naturali.knowledge.createKnowledgeConverter({
path: { project_id: PROJECT },
body: {
content_type: 'application/pdf',
agent_id: OCR_AGENT,
chunk_strategy: 'whole',
},
});
curl -sS -X POST "$NATURALI_API/projects/$PROJECT/knowledge/converters" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"content_type": "application/pdf",
"agent_id": "agent_V1StGXR8Z5jdHi6B",
"chunk_strategy": "whole"
}'
To OCR every PDF regardless of its text layer, create it with
native_extraction: "skip" instead.
5. Route audio to a transcription tool
Speech-to-text endpoints take multipart/form-data and return JSON — they are
not chat completions, so no agent can call one. That is what a tool
converter is for.
First the tool. Three things make it work against a real speech-to-text API:
body_mode: "multipart"— the file field is decoded from base64 and attached as a real file part, instead of being sent as JSON, which such endpoints reject outright.headers— the API key. Headers are write-only: accepted here, never returned by any read of the tool.output_mapping— the endpoint answers with an object like{ "text": "…", "duration": 4.2 }, and a converter must yield a bare string. This reduces the response to just the transcript.
- CLI
- SDK
- curl
naturali create-tool \
--project-id "$PROJECT" \
--name speech-to-text \
--type http \
--description 'Transcribes an audio file.' \
--execute '{"url":"https://api.example-stt.com/v1/stt","method":"POST","body_mode":"multipart","headers":{"Authorization":"Bearer '"$STT_API_KEY"'"}}' \
--output-mapping '{"var":"output.text"}' \
--parameters '{"type":"object","properties":{"file":{"type":"object"},"language":{"type":"string"}}}'
const { data: sttTool } = await naturali.tools.createTool({
path: { project_id: PROJECT },
body: {
name: 'speech-to-text',
type: 'http',
description: 'Transcribes an audio file.',
execute: {
url: 'https://api.example-stt.com/v1/stt',
method: 'POST',
body_mode: 'multipart',
headers: { Authorization: `Bearer ${process.env.STT_API_KEY}` },
},
output_mapping: { var: 'output.text' },
parameters: {
type: 'object',
properties: { file: { type: 'object' }, language: { type: 'string' } },
},
},
});
curl -sS -X POST "$NATURALI_API/projects/$PROJECT/tools" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H 'Content-Type: application/json' \
-d "{
\"name\": \"speech-to-text\",
\"type\": \"http\",
\"description\": \"Transcribes an audio file.\",
\"execute\": {
\"url\": \"https://api.example-stt.com/v1/stt\",
\"method\": \"POST\",
\"body_mode\": \"multipart\",
\"headers\": { \"Authorization\": \"Bearer $STT_API_KEY\" }
},
\"output_mapping\": { \"var\": \"output.text\" },
\"parameters\": {
\"type\": \"object\",
\"properties\": { \"file\": { \"type\": \"object\" }, \"language\": { \"type\": \"string\" } }
}
}"
export STT_TOOL=tool_V1StGXR8Z5jdHi6B
Now the converter. tool_id instead of agent_id is the only structural
difference from step 2; preset_parameters pins arguments the caller never
supplies, and a transcript is one long block of prose, so size chunking
retrieves from it far better than whole would.
- CLI
- SDK
- curl
naturali create-knowledge-converter \
--project-id "$PROJECT" \
--content-type 'audio/*' \
--tool-id "$STT_TOOL" \
--preset-parameters '{"language":"en"}' \
--chunk-strategy size \
--chunk-size 1000 \
--chunk-overlap 200
await naturali.knowledge.createKnowledgeConverter({
path: { project_id: PROJECT },
body: {
content_type: 'audio/*',
tool_id: STT_TOOL,
preset_parameters: { language: 'en' },
chunk_strategy: 'size',
chunk_size: 1000,
chunk_overlap: 200,
},
});
curl -sS -X POST "$NATURALI_API/projects/$PROJECT/knowledge/converters" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"content_type": "audio/*",
"tool_id": "tool_V1StGXR8Z5jdHi6B",
"preset_parameters": { "language": "en" },
"chunk_strategy": "size",
"chunk_size": 1000,
"chunk_overlap": 200
}'
Ingest the recording exactly like the image — the call is identical but for the file and its media type:
- CLI
- SDK
- curl
naturali create-knowledge-document \
--project-id "$PROJECT" \
--collection-id "$COLLECTION" \
--filename meeting.mp3 \
--content-type audio/mpeg \
--file "$(base64 -w0 meeting.mp3)"
await naturali.knowledge.createKnowledgeDocument({
path: { project_id: PROJECT, collection_id: COLLECTION },
body: {
filename: 'meeting.mp3',
content_type: 'audio/mpeg',
file: readFileSync('meeting.mp3').toString('base64'),
},
});
curl -sS -X POST \
"$NATURALI_API/projects/$PROJECT/knowledge/collections/$COLLECTION/documents" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H 'Content-Type: application/json' \
-d "{
\"filename\": \"meeting.mp3\",
\"content_type\": \"audio/mpeg\",
\"file\": \"$(base64 -w0 meeting.mp3)\"
}"
Once it is indexed, query the collection for something that was only ever
said out loud. The transcript is searchable next to the PDF pages and the
receipt — one index, three media types, and the caller never named a converter
for any of them.
What's next
- Knowledge — chunking, the ingestion lifecycle and the converter contract in full.
- Tools —
body_mode,output_mappingand write-only headers, beyond this one example. - Webhooks — a converter run is slower than a PDF
parse, so subscribe to
knowledge.document_ingested/knowledge.ingest_failedrather than polling.