> For the complete documentation index, see [llms.txt](https://k-ai.gitbook.io/knowledge-ai/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://k-ai.gitbook.io/knowledge-ai/sources-and-ingestion/connectors/stonly.md).

# Stonly

K-AI ingests published Articles from a [Stonly](https://stonly.com/) knowledge base via Stonly's public REST API v4 (`https://public.stonly.com/api/v4`). The connector authenticates with a Stonly personal API token, walks one folder and its whole subtree, exports each published Article in one language, converts it to Markdown, and embeds the Article's inline images. The typical use case is making a Stonly knowledge base — standard operating procedures, support playbooks, help-center content — searchable through K-AI MCP and auditable by K-AI Audit, with Stonly remaining the system of record.

Each K-AI source targets **one folder in one language**. The folder is the knowledge base: it covers every sub-folder beneath it.

Everything the connector does against Stonly is **read-only**.

## Supported source versions

* **Stonly cloud** — the hosted product, through the public API v4 at `public.stonly.com`. The v4 API has a single host for all regions; leave `base_url` at its default.
* **Articles only** — the connector ingests guides of type `ARTICLE` with status `PUBLISHED`. Guides (decision trees), guided tours, and drafts are not ingested. Raise the requirement with the K-AI integration team if you need them.
* **One folder per source** — a source is scoped to a single folder subtree and a single language. To ingest several folders or languages, register one source each (see [Scope rules](#scope-rules)).

## Authentication

The connector uses a Stonly **personal API token** (PAT), sent as `Authorization: Bearer stonly_pat_…`. The token is stored encrypted at rest on the K-AI side and is never sent back to the browser: once saved, the admin portal browses folders by referencing the stored credential, not the token itself.

A Stonly PAT has four properties that shape the setup:

| Property                                               | What it means for the connector                                                                                           |
| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------- |
| Acts **as the user who created it**                    | The connector reads exactly what that user can read. An Article that user cannot see is not ingested.                     |
| Needs scopes **`read:folder`** and **`read:guide`**    | Both are required. Missing either one parks the source in `ERROR`.                                                        |
| Needs access level **Commenter** or higher             | On the folder you ingest and everything under it.                                                                         |
| **Expires** — 30 days by default, **365 days at most** | Stonly offers no refresh and no API to read the expiry date. When the token expires, every source using it stops syncing. |

{% hint style="warning" %}
**Create the token with the maximum lifetime (365 days) and write its expiry date down.** K-AI cannot read the expiry date from Stonly. Record it in the credential's **Rotation** field as a reminder, and rotate the token before that date. See [Rotating the token](#rotating-the-token).
{% endhint %}

### Creating the token in Stonly

1. Sign in to Stonly with an account that has at least **Commenter** access on the folder to ingest. Prefer a dedicated service account over a personal one: the token stops working if its user loses access or leaves the team.
2. Open **User settings → Personal API tokens** and create a new token.
3. Grant the scopes **`read:folder`** and **`read:guide`**. No write scope is needed.
4. Select the Stonly team(s) that own the folder.
5. Set the expiry to **365 days**.
6. Copy the token (it starts with `stonly_pat_`) and note the expiry date.

## Configuration

**Credential** — stored once and reusable across several sources:

| Field            | Type    | Required | Description                                                                                                                                       |
| ---------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `api_token`      | string  | yes      | The Stonly personal API token (`stonly_pat_…`). Encrypted at rest by the platform.                                                                |
| `root_folder_id` | integer | no       | Only for a token bound to **several Stonly teams**: the folder id of the team root the portal should browse. Leave empty for a single-team token. |
| `base_url`       | string  | no       | API base URL. Defaults to `https://public.stonly.com/api/v4`. Must be a full `https://` URL without credentials in it.                            |

When the credential is saved, K-AI probes it: the token must authenticate against Stonly. A credential that fails the probe is rejected with the message from [Errors](#errors).

**Scope** — which folder and which language to ingest, expressed as a path:

```
folder:<folder_id>/lang:<language>
```

* `folder` — required; the numeric Stonly folder id. The source covers this folder **and its whole subtree**.
* `lang` — required; a language code such as `en`, `fr`, or `pt-BR`. It must be a language the folder's published Articles actually carry.

The K-AI admin portal builds this for you. After the credential is saved, the portal lists the folder tree visible to the token, with each folder's full path and a filter box. Pick a folder; the language field is pre-filled with the folder's default language, and you can change it. You do not have to assemble the path by hand.

When the source is created, K-AI checks that the folder exists, that its subtree can be read, and that at least one published Article in it carries the requested language. A source that would ingest zero documents is refused at creation instead of failing silently later.

## Scope rules

Because a source covers a whole subtree, two sources of the same organization **in the same language** cannot be nested — one folder cannot be an ancestor of the other. Such a source is refused with:

```
400 overlapping_stonly_scope
Stonly folder <id> (language 'en') overlaps existing source <source_id> on folder <id>: one folder is a strict ancestor or descendant of the other, and nested folders are refused (several sources on the SAME folder are allowed when their metadata filters differ).
```

The check runs again at every sync, so moving a folder in Stonly into another ingested folder is also detected.

What is allowed:

* The **same folder in two languages** — two sources, `folder:<id>/lang:en` and `folder:<id>/lang:fr`.
* **Sibling folders** — two folders neither of which contains the other.
* **The same folder several times, with different** [**metadata filters**](#metadata-filter) — for instance one source for `Confidentiality = Public` and one for `Confidentiality = Internal`, attached to different K-AI instances. A source without a filter can coexist with filtered sources on the same folder. Two sources on the same folder and language with the **same** rule (or both without a rule) are refused with `409 source_scope_exists`.

To ingest a large tree as several sources, pick sibling sub-folders rather than a parent and one of its children.

## Metadata filter

A Stonly source can carry a **metadata filter**: a rule on the guides' Stonly **custom metadata** (`Confidentiality`, `Document type`, `Category`…) that decides which Articles of the folder enter the Document Repository. Use it to ingest only the `Public` Articles of a folder, or to split one folder into several sources, one per value of a metadata — each attached to a different K-AI instance.

The filter is optional. Without it, every published Article of the folder is ingested, exactly as before.

{% hint style="warning" %}
**The rule is set at creation and is immutable.** It cannot be added, changed or removed afterwards (`400 metadata_filter_immutable`). To change it, create a new source with the new rule — see [Changing the rule](#changing-the-rule).
{% endhint %}

### Rule format

The rule is stored under `config.metadata_filter` on the source. It is a flat **AND** of conditions; each condition names one metadata and one or several accepted values:

```json
{
  "schema_version": 1,
  "filter": {
    "kind": "group",
    "op": "and",
    "children": [
      { "kind": "leaf", "op": "eq", "field": "Confidentiality", "value": "Public" },
      { "kind": "leaf", "op": "in", "field": "Document type", "value": ["Article", "FAQ"] }
    ]
  }
}
```

| Element      | Rules                                                                                                                                     |
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------- |
| Root         | One `group` with `op: "and"`. No `or`, no nested group. A group with no condition, or `"filter": null`, means "no rule".                  |
| `leaf.op`    | `eq` (one string) or `in` (1 to 50 strings).                                                                                              |
| `leaf.field` | The metadata **name** as shown in Stonly — free text, spaces allowed, at most 50 characters. Two conditions on the same name are refused. |
| `leaf.value` | Non-empty strings of at most 500 characters.                                                                                              |
| Limits       | 1 to 20 conditions. Control characters are refused.                                                                                       |

**Canonical form.** Names and values are stored normalized — Unicode NFC, trimmed, **lower-cased** — duplicate values are removed and sorted, a one-value `in` becomes an `eq`, and conditions are sorted by name. So `Public` and `public`, `eq X` and `in [X]`, or the same conditions in another order are the same rule: this is what makes the duplicate check (`409 source_scope_exists`) reliable.

A malformed rule is refused with `400 invalid_metadata_filter`.

### Matching semantics

A guide's metadata are read with `GET /guides/{guideId}/custom-metadata`. Names are compared case-insensitively; values too. A list metadata (multi-select) matches when **any** of its selected options is accepted. Each guide gets one of three verdicts:

| Verdict      | When                                                                                           | Effect                                                              |
| ------------ | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
| **match**    | Every condition names a metadata the guide carries, with an accepted value                     | The Article is ingested.                                            |
| **reject**   | At least one condition names a metadata the guide carries, with another value                  | The Article is not ingested; if it was, it is removed at that sync. |
| **withheld** | The guide lacks a metadata the rule names, or its metadata have not been read successfully yet | The Article is **neither ingested nor removed**.                    |

{% hint style="info" %}
**In doubt, nothing is deleted.** An Article is removed only when a fresh read proves it no longer matches. A metadata Stonly omits (a guide with no value for it) withholds the Article rather than rejecting it.
{% endhint %}

### How the filter applies during sync

Stonly's listing does not carry custom metadata, and the API cannot filter on them server-side. So the filter is applied **after a read**, through a per-guide metadata cache:

1. **At creation and at each sync**, the connector lists the folder as usual. Every listed guide is queued for a metadata read. A guide never read yet is withheld — so **the first sync of a filtered source ingests nothing**.
2. **In the background**, K-AI reads the queued guides' metadata, paced at about one read per second per token (Stonly's quota is 120 requests per minute per token, shared with ingestion). A large folder takes a while to be read the first time.
3. **At the next sync**, each guide gets its verdict from the cache: matching guides are ingested, rejected ones are removed, withheld ones are left as they are.

Metadata are re-read about every 2.5 hours, under the 3-hour sync interval, so a metadata changed in Stonly moves the Article out of (or into) a filtered source within a few hours: re-read, then next sync. Unpublishing an Article still removes it, as for any Stonly source.

### Endpoint

**Test a rule on a sample** of the folder before creating the source. The same call **without** a rule lists the metadata names and values actually present in the folder — the token cannot list Stonly's metadata definitions (`read:metadata` is not required), so this is how names are discovered:

```http
POST /v1/sources/stonly/metadata-preview?org_id=<org_id>
{
  "credential_id": "<credential_id>",
  "scope_path": "folder:<folder_id>/lang:<language>",
  "metadata_filter": { "schema_version": 1, "filter": { … } }
}
```

Returns, over a **random sample** of about ten published Articles of the folder:

| Field                 | Meaning                                                                                                                                            |
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `sampled` / `matched` | Guides read, and how many match the rule.                                                                                                          |
| `matches[]`           | `guide_id`, `title`, `web_url` of the matching guides.                                                                                             |
| `observed_fields[]`   | Metadata names seen, with their `type` and, for list metadata, the option labels seen (`values`, at most 50). Free-text values are never returned. |
| `warnings[]`          | `field_never_observed` (no sampled guide carries that name — usually a typo) or `value_never_observed`.                                            |
| `unreadable`          | Sampled guides whose metadata could not be read; not counted either way.                                                                           |
| `scan_complete`       | `false` when the sample stopped early (rate limit or time budget): a `0` then proves nothing.                                                      |

**Create the source** with the rule in `config`:

```http
POST /v1/sources
{ "kind": "STONLY", "scope_path": "folder:<folder_id>/lang:<language>",
  "credential_id": "…", "org_id": "…",
  "config": { "metadata_filter": { "schema_version": 1, "filter": { … } } } }
```

`GET /v1/connectors` reports `supports_metadata_filter: true` for `STONLY`.

### Changing the rule

The rule is immutable: create a new source with the new rule, then delete the old one. If the source is attached to a K-AI instance, attach the new source first and **wait until it has ingested its Articles** (one sync after its metadata have been read) before detaching the old one — detaching first removes the documents from the instance until the new source brings them back.

### In the admin portal

The source creation wizard shows an optional **Filter by metadata** step for Stonly. **Test on a sample** runs the preview and lists the metadata seen in the folder — click a name, then a value, to fill a condition without typing. The rule is shown in lowercase, and a permanent notice reminds that it cannot be changed after creation. A filtered source carries a `Filtered: …` chip in the sources table.

## Document types ingested

* **Articles** — each published Article is exported at its **published** version (never the unpublished draft) and converted from Stonly's HTML to **Markdown** (`text/markdown`). Accordions, callout boxes (warning, info…), and tables — including tables whose cells hold lists or several paragraphs — are preserved. Inline images are downloaded with the same token and embedded directly in the Markdown as data URIs, so an Article is self-contained. Small icons are skipped.
* **Domain header** — each document starts with the Article's folder path, starting at the source's folder, followed by a short declarative block naming that domain:

  ```markdown
  # [Support Handbook / Billing / Refunds] How to issue a partial refund

  This document is contextually part of the following knowledge-base domain, which delimits the business scope its information applies to:
  - Domain: Support Handbook / Billing / Refunds
  ```

  K-AI Audit uses this domain to decide which documents can contradict each other; K-AI MCP returns it with every result, so an agent knows which procedure family an answer comes from. Name your Stonly folders the way you want the domain to read.
* **Source link** — each document links back to the Article in the **Stonly editor** (`https://app.stonly.com/app/guide/<guideId>/editor`), the page where a Steward can correct it. Opening it requires a Stonly login.
* **Metadata** — the document path is the folder breadcrumb; the Stonly guide id, folder id, language, published and current version numbers, and creation/update/publication timestamps are kept as structural metadata.

Step attachments are not ingested.

## Sync mode

Stonly does not expose a change feed or deletion events, so **every sync is a full crawl** of the scoped folder subtree: one call for the folder tree, then one paginated listing per folder. The listing is sorted on a stable creation key, so an Article edited during the crawl cannot shift pagination.

Update detection is based on the Article's **published version** and its **folder**: an Article is re-exported only when a new version is published or when it is moved to another folder. Unchanged Articles cost no export call.

Deletions have no native tombstone. They are detected by an **orphan scan** after each full crawl: any Article not seen in the current crawl is removed on the K-AI side. A completeness guard aborts the orphan scan when the crawl walked fewer Articles than Stonly reports, so a partial or interrupted crawl never triggers a mass deletion — the cycle is retried instead.

{% hint style="warning" %}
**Unpublishing an Article in Stonly deletes its K-AI document at the next sync.** It is not a pause. The document is removed from every K-AI instance that uses the source. Republishing the Article ingests it again as new.
{% endhint %}

**Renaming a folder** does not change any Article's version, so the domain header of the Articles beneath it is refreshed only when each Article is next published. Ask the K-AI integration team for a forced refresh if a rename must propagate immediately.

## Languages & multi-language

Stonly stores one translation per language for each Article. Because the language filters every export:

* One K-AI source indexes exactly one language.
* The same Article ingested under two languages becomes **two K-AI documents**, so retrieval can return the reader's language.
* An Article that does not exist in the source's language is skipped — never ingested in its default language under the wrong label. (Stonly's export silently falls back to the default language; the connector filters that out.)
* The source link of a non-default language carries `?kai_lang=<code>` so each language keeps a distinct link.

## ACL handling

Stonly's API does not expose per-Article access rules, so the connector does **not** propagate a native ACL. What a source can read is bounded by the Stonly access of the token's user.

Apply the **mapped** ACL strategy, as with [Mayday](/knowledge-ai/sources-and-ingestion/connectors/mayday.md) and [ServiceNow](/knowledge-ai/sources-and-ingestion/connectors/servicenow.md): attach a K-AI group rule at source registration, and every document ingested from the source inherits that group. For finer-grained control, scope separate sources to separate folders by audience and assign distinct group rules. At retrieval time, [K-AI MCP](/knowledge-ai/k-ai-mcp/mcp.md) replays the mapped ACL against the calling identity.

## Rate limits & throttling

Stonly enforces per-endpoint quotas on each token and announces them on every response (`X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`). The connector paces its calls from these headers, caps its concurrency, and on a `429` honours `Retry-After` with jitter. A throttled document is rescheduled without spending its retry budget and without being billed; the source stays `ACTIVE`.

Practical consequences:

* **First sync of a large knowledge base takes time.** Each Article needs two API calls on its first ingestion. A knowledge base of about 1,300 Articles typically completes its first ingestion in tens of minutes. Start with a small folder, check the result, then add the larger ones.
* **Use at most about 4 sources per token.** The quota belongs to the token, so the crawls of all sources sharing one token are serialized. Past about 4 large sources, syncs no longer fit their time budget. Create an additional token (it can belong to the same Stonly user) for further sources.
* **The quota is your Stonly quota.** The token acts as one of your users; other integrations that use the same user's tokens share the same limits.

## Rotating the token

1. Create a new token in Stonly with the same scopes, access, and a 365-day expiry (see [Creating the token in Stonly](#creating-the-token-in-stonly)).
2. In the K-AI admin portal, open the **Credentials** tab and use **Rotate** on the Stonly credential. Paste the new token and update the expiry date in the **Rotation** field. K-AI probes the new token before saving it.
3. If the old token had already expired, the sources using it are in `ERROR`. Rotation does not reactivate them: use **Retry** on each source. Retry re-probes the source and returns it to `ACTIVE`.

Documents already ingested stay indexed while a source is in `ERROR`; they simply stop being refreshed.

## Errors

Error messages lead with the remediation, then quote Stonly's own message.

| Situation                   | Message                                                                                                                                                                                                                                                | What to do                                                                                                                   |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
| Token expired               | `[kai] Stonly rejected the personal API token. It has expired: a PAT lives 30 days by default and 365 at most, and cannot be refreshed. Create a new token under User settings -> Personal API tokens (365 days), then rotate the credential.`         | [Rotate the token](#rotating-the-token), then **Retry** each source.                                                         |
| Token unknown               | `[kai] Stonly rejected the personal API token. It is unknown to Stonly -- revoked, mistyped, or created on another instance. Create a new token …`                                                                                                     | Check the token was copied in full, or create a new one.                                                                     |
| Missing scope or access     | `[kai] The Stonly token is valid but not allowed here: it needs the read:folder and read:guide scopes, and its user needs at least COMMENTER access on the folder.`                                                                                    | Recreate the token with both scopes, or raise the user's access on the folder.                                               |
| Folder not found            | `Stonly scope folder <id> was not found: …`                                                                                                                                                                                                            | The folder was deleted, or is not visible to the token's user. Pick the folder again in the portal; check the token's teams. |
| No Article in that language | `[kai] no PUBLISHED article under Stonly folder <id> (or its subtree) carries the language '<code>' -- this source would discover zero documents. Pick a folder/language pair that actually has published content, or double-check the language code.` | Choose a language the folder's published Articles carry.                                                                     |
| Nested scope                | `400 overlapping_stonly_scope`                                                                                                                                                                                                                         | See [Scope rules](#scope-rules).                                                                                             |
| Same folder, same rule      | `409 source_scope_exists`                                                                                                                                                                                                                              | A source already covers this folder and language with the same metadata filter (or both have none). Use a different rule.    |
| Malformed rule              | `400 invalid_metadata_filter`                                                                                                                                                                                                                          | See [Rule format](#rule-format).                                                                                             |
| Rule changed after creation | `400 metadata_filter_immutable`                                                                                                                                                                                                                        | Create a new source — see [Changing the rule](#changing-the-rule).                                                           |
| Rate limited                | `429` with `Retry-After` in the folder picker                                                                                                                                                                                                          | Wait the indicated delay; syncs retry on their own.                                                                          |

An image that cannot be downloaded (deleted in Stonly, for instance) is dropped; the rest of the Article is ingested.

## Known limitations

* **Articles only** — guides (decision trees), guided tours, drafts, and step attachments are not ingested.
* **No native ACL** — access on the K-AI side is group-rule based, not replicated from Stonly.
* **Full crawl only** — no incremental sync; each cycle re-lists the scoped folders (unchanged Articles are not re-exported).
* **One language per source** — multi-language coverage requires one source per language.
* **No nested sources** — two sources of one organization in the same language cannot be ancestor and descendant.
* **Metadata filter** — immutable once the source exists; a flat AND of `eq` / `in` conditions (no OR, no negation, no "is empty"); names and values are free text, so check them with the preview; a filtered source ingests nothing until its guides' metadata have been read.
* **Token expiry** — at most 365 days, no refresh; rotation is manual.
* **Folder renames** — reach the domain header only when each Article is next published.
* **Consecutive numbered lists** — two ordered lists that follow each other in one Article are ingested as a single continuous numbering: a second list starting at `1.` may read `11.`. The text itself is intact; only the step numbers of the second list are off. Tracked as a known defect.

## Setup walkthrough

1. **Create a Stonly personal API token** — scopes `read:folder` + `read:guide`, a user with at least Commenter access on the folder, 365-day expiry. Note the expiry date.
2. **Add the credential in the K-AI admin portal**: select Stonly, paste the token, and record the expiry date in the **Rotation** field. Leave `root_folder_id` and `base_url` empty unless the token spans several Stonly teams.
3. **Pick the scope** — the portal lists the folder tree visible to the token. Choose a folder, then confirm or change the language. This builds the `folder:…/lang:…` scope for you.
4. **Optional — set a metadata filter** to ingest only part of the folder: run **Test on a sample** to see the metadata the folder carries, write the rule, test it again, then create. The rule cannot be changed later. See [Metadata filter](#metadata-filter).
5. **Assign a group rule** for ACL mapping — every document ingested from this source inherits that group on the K-AI side.
6. **Set the sync schedule** and **trigger a test sync** — start with a small folder.
7. **Confirm the document count** in the indexing statistics of the instance page in the K-AI Studio portal.
