Organizing a Custom Source into Folders
Sync a folder tree to a custom source, tag records with their folder, and scope filters and Knowledge Agents by folder.
Many libraries are organized as folder trees, and people expect to find and filter records by folder. A custom source models a folder tree with a HIERARCHICAL tag: a set of folders you sync to Guru, each with an id, a display name, and an optional parent. Each record is tagged with the folder it sits in, and a facet over the tag lets people filter by folder and scope Knowledge Agents to part of the tree.
This guide extends Creating a Custom Source. The examples use files uploaded as in Pushing Files to a Custom Source, but folders work the same way for records pushed as JSON: the folder travels in the same X-Guru-Object-Tag header either way.
Endpoints
All endpoints below are relative to the API root https://api.getguru.com/api/v1/ and use basic auth with your Guru user and API token.
| Endpoint | Purpose |
|---|---|
PUT /sources/{sourceId}/types/{tagConfigId}/status | Open and close a folder sync. |
PUT /sources/{sourceId}/tagconfigs/{tagConfigId}/hierarchies/{externalId} | Create or update one folder. |
DELETE /sources/{sourceId}/tagconfigs/{tagConfigId}/hierarchies/{externalId} | Delete one folder outside a sync. |
GET /sources/{sourceId}/facets/{facetId}/hierarchies | List the root folders. |
GET /sources/{sourceId}/facets/{facetId}/hierarchies/{externalId}/children | List a folder's direct children. |
Step 1: Declare the folder tag
Add a HIERARCHICAL entry to the object type's tagConfigs and a TAG facet over it when you create the source. It can sit alongside SIMPLE tags such as a file extension:
{
"name": "Document",
"externalId": "document",
"sourceDataType": "RECORD",
"fields": [ ... ],
"tagConfigs": [
{"type": "HIERARCHICAL", "name": "Folder", "externalId": "folder", "dataType": "TEXT", "allowMultipleValues": false},
{"type": "SIMPLE", "name": "Extension", "externalId": "extension", "dataType": "TEXT", "allowMultipleValues": false}
],
"facets": [
{"type": "TAG", "name": "Folder", "tagConfig": {"externalId": "folder"}, "allowMultipleValues": false},
{"type": "TAG", "name": "Extension", "tagConfig": {"externalId": "extension"}, "allowMultipleValues": false}
]
}The folder endpoints are keyed by ids from the create response, so store two more alongside the source and object type ids: the tag config's id from sourceObjectTypes[].tagConfigs[], and the facet's id from sourceObjectTypes[].facets[]. A facet over a HIERARCHICAL tag has "hierarchical": true in that response.
The folder tree gets its own tracked sync, separate from the object type's, with its own sync number. Sync the folders first, then push the records that reference them.
Folders needtrackStatus: trueFolders need a source created with
config.trackStatus: true, as every sync in these guides does. On a source without it, the folder endpoints return a 404.
Step 2: Open a folder sync
Open the folder sync with the same status endpoint that brackets a record sync, passing the tag config id where an object type id would go:
curl -X PUT "https://api.getguru.com/api/v1/sources/$SOURCE_ID/types/$FOLDER_TAG_CONFIG_ID/status" \
-u $GURU_USER:$GURU_TOKEN \
-H "Content-Type: application/json" \
-d '{
"statusAction": "START",
"maxSyncTimeInMinutes": 60,
"dependentObjectTypeIds": []
}'START works for the first folder sync and every one after it. The response names the folder tag and carries the sync number in objectType.currentSyncNumber:
{
"lastSyncAttempt": "2026-09-23T00:46:04.120+0000",
"objectType": {
"name": "Folder",
"id": "52c526a6-7137-449f-8211-5470384a0add",
"currentSyncNumber": 5
},
"syncStatus": "SYNCING",
"tagConfig": {
"id": "52c526a6-7137-449f-8211-5470384a0add",
"name": "Folder",
"currentSyncNumber": 5
}
}If the source also syncs folder permissions, the tag config id still reaches the folder sync. The folder permission sync has its own id, TAG_ACCESS~{tagConfigId}.
Step 3: Push each folder
Send one request per folder. $FOLDER_EXTERNAL_ID in the path is the folder's id in your source system, f-hr in this example. Guru always uses the path value, so externalId in the body is optional and ignored if it differs.
curl -X PUT "https://api.getguru.com/api/v1/sources/$SOURCE_ID/tagconfigs/$FOLDER_TAG_CONFIG_ID/hierarchies/$FOLDER_EXTERNAL_ID?syncNumber=$FOLDER_SYNC_NUMBER" \
-u $GURU_USER:$GURU_TOKEN \
-H "Content-Type: application/json" \
-d '{
"value": "HR",
"parentExternalId": "f-policies"
}'| Field | Description |
|---|---|
value | Required. The folder's display name, under 500 characters. |
parentExternalId | The parent folder's externalId. Leave it out for a root folder. |
externalId | Optional. The folder's id, taken from the path. Records are tagged with this value. |
A new folder returns 201 Created and an existing one returns 200 OK, with the folder in the body:
{
"value": "HR",
"externalId": "f-hr",
"parentExternalId": "f-policies",
"modifiedDate": "2026-09-23T00:31:25.349+0000"
}To rename or move a folder, push it again with the new value or parentExternalId. Guru updates the folder paths of every record under it, so folder filters (Step 6) follow the move.
You can push folders in any order. A folder whose parent doesn't exist yet is accepted, and it moves under the parent once you push it, so there's no need to walk the tree top-down.
The syncNumber parameter ties the folder to the open sync. You can also create or update a folder without it, outside any sync, but the next COMPLETE then removes it unless it's pushed again with that sync's number.
Step 4: Close the folder sync
curl -X PUT "https://api.getguru.com/api/v1/sources/$SOURCE_ID/types/$FOLDER_TAG_CONFIG_ID/status" \
-u $GURU_USER:$GURU_TOKEN \
-H "Content-Type: application/json" \
-d '{
"statusAction": "COMPLETE",
"syncNumber": '"$FOLDER_SYNC_NUMBER"'
}'COMPLETE reconciles the tree: every folder not pushed with this sync number is removed. A sync number that isn't the active folder sync fails with a 409.
Step 5: Tag records with their folder
On each push, send the folder's externalId as the tag value. For a file upload:
curl -X PUT "https://api.getguru.com/api/v1/sources/$SOURCE_ID/objecttypes/$OBJECT_TYPE_ID/objects/$RECORD_EXTERNAL_ID/file?syncNumber=$SYNC_NUMBER" \
-u $GURU_USER:$GURU_TOKEN \
-H "X-Guru-Object-Tag: folder:f-hr" \
-H "X-Guru-Object-Tag: extension:pdf" \
-F "[email protected];type=application/pdf"For a JSON push to /content, add the same header.
Tag with the folder'sexternalId, not its nameTag records with the folder's
externalId, never its name. Guru doesn't check the value against the folder tree:folder:HRor a mistyped id is accepted and stored, but it matches no folder, so the record never shows up under one.
Step 6: Filter by folder
Guru indexes each record under its own folder and under every folder above it. That gives two ways to scope to a folder, and when someone connects the source to a Knowledge Agent in Guru and picks a folder, they choose between them:
| Option | Matches |
|---|---|
| This folder and all its subfolders | Records in the folder and anywhere below it. |
| This folder only | Records tagged directly with the folder, and nothing in its subfolders. |
To set the same scope through the API, add a source filter to the Knowledge Agent's filterConfig when you create or update it (POST /knowledgeagents, PUT /knowledgeagents/{agentId}). Filters are keyed by source id, and each one names the object type and a sourceCustomField expression on the folder facet:
{
"filterConfig": {
"sourceFilters": {
"0070bcf1-5c9b-4024-8b02-c4ee01ff2012": [
{
"objectTypeId": "0d3d2f26-d0bb-4a4d-adb7-3fcdbafa0eca",
"filterQuery": {
"type": "sourceCustomField",
"fieldId": "10e99a17-ef29-44bc-ac61-d7f536442814",
"fieldValue": "f-legal",
"op": "CONTAINS"
}
}
]
}
}
}| Field | Description |
|---|---|
type | sourceCustomField. |
fieldId | The folder facet's id. |
fieldValue | The folder's externalId. |
op | CONTAINS for this folder and all its subfolders, or EQUALS for this folder only. |
Guru doesn't check that fieldValue names an existing folder when you save the agent. A filter on an unknown externalId is accepted and matches nothing.
When a folder is removed
Removing a folder, by a COMPLETE sync or with DELETE, doesn't remove anything under it. Its child folders keep their parentExternalId and are listed as root folders until a folder with that externalId is pushed again, at which point they move back under it. Records tagged with the removed folder keep the tag value. DELETE returns 204 No Content whether or not the folder existed.
Read the folder tree
The folder facet exposes the tree. List the root folders, then walk down with children, where $FOLDER_EXTERNAL_ID is the parent folder's id (f-policies in this example):
curl "https://api.getguru.com/api/v1/sources/$SOURCE_ID/facets/$FOLDER_FACET_ID/hierarchies/$FOLDER_EXTERNAL_ID/children" \
-u $GURU_USER:$GURU_TOKEN[
{
"value": "HR",
"externalId": "f-hr",
"parentExternalId": "f-policies",
"modifiedDate": "2026-09-23T00:31:25.349+0000"
}
]Both list endpoints use standard pagination. On a source with native permissions, each user sees only the folders they have access to (see Set permissions on folders).
Updated 1 day ago

