Train a bot
Training is how a chatbot stops being a generic model and starts answering from your content. You attach sources, the platform extracts and indexes the text, and from then on answers are grounded in what you gave it.
This page covers every source type, what happens after you submit one, and how to tell the difference between "still working" and "broken".
What a knowledge source is
Each source is a dataset: a name, some text, and the chatbot it belongs to. Where the text came from — pasted, uploaded, or crawled — only matters at creation. After extraction they are all the same thing.
Datasets can be grouped into collections, which is useful once you have more than a handful. A collection is organisational; it does not change how retrieval works.
POST/v1/knowledge
Two fields decide the type of a text or web-page dataset:
| Field | What it means |
|---|---|
type | The content format: Text for inline text, URL for a web page |
source_type | Where the content comes from: text (in content) or url (fetched from source_url) |
A dataset made from an uploaded file needs neither: send file_id and the
file's extension sets the type. Datasets you read back can also carry the
source_type of the path that made them — upload, url_links, sitemap,
google_drive, dropbox or site_info.
Raw text
The simplest source, and the best one for content you generate yourself — product data, FAQs assembled from a database, policy text.
curl -X POST https://api.agentency.com/v1/knowledge \
-H "Authorization: Bearer <YOUR_KEY>" \
-H "Content-Type: application/json" \
-d '{
"chatbot_id": 1,
"name": "Shipping FAQ",
"type": "Text",
"source_type": "text",
"content": "We ship within 2 business days. Express delivery arrives next day.",
"auto_train": true
}'Inline text is capped at 1,000,000 characters by default. For anything long, upload it as a file instead — you get the same result without fighting a payload cap.
Files
Upload first, then reference the returned id. Splitting it in two means a
failed upload never leaves you with a half-created dataset: a file whose text
cannot be read is refused with 422 TEXT_EXTRACTION_FAILED before any dataset
exists, and you still hold the file to fix or delete. A file backs one dataset
only — naming it in a second POST /v1/knowledge is 409
FILE_ALREADY_ATTACHED.
- POST /v1/files with the file as multipart form data.
- Take
idfrom the response — the integer, not the UUID infile_id. - POST /v1/knowledge with
nameandfile_id. The type follows the file's extension.
Accepted: txt, pdf, doc, docx, rtf, odt, images (png, jpg,
jpeg, webp) and spreadsheets (xlsx, xls, csv, ods), at most 10 MB
each by default. The content must match the extension.
# 1. Upload
curl -X POST https://api.agentency.com/v1/files \
-H "Authorization: Bearer <YOUR_KEY>" \
-F "file=@handbook.pdf"
# 2. Attach
curl -X POST https://api.agentency.com/v1/knowledge \
-H "Authorization: Bearer <YOUR_KEY>" \
-H "Content-Type: application/json" \
-d '{
"chatbot_id": 1,
"name": "Handbook",
"file_id": 1,
"auto_train": true
}'To import a folder, send them together and let one Idempotency-Key cover the
batch — up to 20 files and 90 MB, stored all or nothing. The response is a
list rather than a single object:
curl -X POST https://api.agentency.com/v1/files \
-H "Authorization: Bearer <YOUR_KEY>" \
-H "Idempotency-Key: import-2026-01-14" \
-F "files[]=@handbook.pdf" \
-F "files[]=@pricing.xlsx"Scanned PDFs and images go through OCR automatically. That takes longer than text extraction, which is worth knowing before you decide your polling interval.
A single web page
Give it a URL and the page is fetched, cleaned of navigation and boilerplate, and indexed.
curl -X POST https://api.agentency.com/v1/knowledge \
-H "Authorization: Bearer <YOUR_KEY>" \
-H "Content-Type: application/json" \
-d '{
"chatbot_id": 1,
"name": "Pricing page",
"type": "URL",
"source_type": "url",
"source_url": "https://example.com/pricing",
"auto_train": true
}'Use POST /v1/knowledge/{id}/recrawl to refetch it later — that is the right
call when a page changes, rather than creating a second dataset for the same
URL.
A whole site
For more than a page or two, use a crawler. Two kinds:
- Website crawler — follows links from a starting URL, within limits you set.
- Sitemap crawler — reads
sitemap.xmland fetches what it lists. More predictable, and the better choice when a sitemap exists.
POST/v1/sitemap_crawlers
Check the sitemap before committing to it. This costs nothing and mutates nothing, so it only needs read access:
curl -X POST https://api.agentency.com/v1/sitemap_crawlers/validate_url \
-H "Authorization: Bearer <YOUR_KEY>" \
-H "Content-Type: application/json" \
-d '{"sitemap_url": "https://example.com/sitemap.xml"}'{
"object": "sitemap_validation",
"url": "https://example.com/sitemap.xml",
"valid": true,
"type": "sitemap",
"url_count": 143,
"has_lastmod": true,
"errors": [],
"warnings": []
}type is sitemap for a list of pages and sitemap_index for a list of
sitemaps. Then create the crawler: it queues its first scan by itself, and
later scans follow its scan_frequency. POST /v1/sitemap_crawlers/{id}/scan
runs one on demand — the way to refresh a manual crawler. Crawls run in the
background and can take a while on a large site — subscribe to
crawl.completed and crawl.failed rather than polling for minutes.
Creating a crawler for a URL and chatbot that already has one pending or
running returns that crawler (200) instead of a second one. An account runs
a limited number of crawls at once (5 website and 10 sitemap crawls by
default); one more is 429 CRAWLER_LIMIT_REACHED.
crawler.* and crawl.* events carry only {object, id}, and website and
sitemap crawlers are numbered separately. Keep each crawler id together with
its kind when you create it.
URLs are validated before they are fetched, and internal or private addresses are refused. Crawl your own content, or content you have permission to use.
Cloud imports
POST /v1/knowledge/import/cloud pulls documents straight from Google Drive
or Dropbox. Your side supplies short-lived references — the Google Picker's
access_token and Drive file ids, or the Dropbox Chooser's direct links; the
platform downloads each file and feeds it through the same pipeline as an
upload. One Idempotency-Key covers the whole batch. Files that could not be
imported come back in failures with a reason. Imported datasets emit no
knowledge.dataset.created; their knowledge.dataset.trained and
knowledge.dataset.failed fire as usual.
Knowing when it is ready
Every dataset carries embedding_status:
| Status | Meaning |
|---|---|
not_started | Created, no training requested yet (no chatbot_id, or auto_train: false) |
pending | Queued |
processing | Chunking and embedding |
completed | Usable in answers |
failed | Training failed. /v1 does not return the reason; the dashboard's Knowledge page shows it |
A completed dataset with total_chunks: 0 is not broken: every passage in it
repeated knowledge another trained dataset of the same collection already
holds, so training skipped it.
Two endpoints read it:
GET/v1/knowledge/{id}/status
GET/v1/chatbots/{id}/readiness
The first is about one source. The second is about the bot: ready is true
once the chatbot is active and its training is completed, alongside how
many documents and how much knowledge it holds.
knowledge.dataset.trained and knowledge.dataset.failed fire the moment a
source reaches a terminal state, from every path — upload, crawl, re-embed and
retry alike. A poll loop that never sees failed will wait forever.
Changing and removing content
- Edit —
PATCH /v1/knowledge/{id}to change the name or metadata, or thecontentof atextdataset (retrained unless you sendretrain: false). - Replace the file —
POST /v1/knowledge/{id}/replace_filekeeps the dataset and swaps its contents, so anything referencing it stays valid. - Retry —
POST /v1/knowledge/{id}/retryafter a transient failure;POST /v1/knowledge/{id}/retry_failedretrains every failed dataset of a chatbot ({id}is the chatbot id there). - Preview a removal —
POST /v1/knowledge/removal_impactcounts how many of the ids a removal would act on. Read-only, so a read-scoped key can call it. - Remove —
POST /v1/knowledge/remove_from_chatbotdetaches datasets from one bot and keeps them;POST /v1/knowledge/remove_from_system(withconfirm_deletion: true) orDELETE /v1/knowledge/{id}deletes them everywhere and emitsknowledge.dataset.deleted.
Removal is asynchronous too: a deleted dataset stays readable with
is_deleting: true until the background removal finishes, and every change to
it meanwhile is 409 DATASET_DELETING.
Common mistakes
- Chatting before training finishes. The call succeeds and the answer is
ungrounded —
sourcescomes back empty. Check readiness first. - Re-uploading instead of recrawling. You end up with two datasets saying slightly different things, and retrieval has to pick.
- Polling a failed dataset forever.
failedis terminal. Check the source (the dashboard's Knowledge page shows why it failed), fix it, then retry. - Ignoring the quota. Knowledge is metered per plan, in extracted
characters.
GET /v1/knowledge/usageshows where you are before a create is refused with 402KNOWLEDGE_PLAN_SIZE_EXCEEDED.