> ## Documentation Index
> Fetch the complete documentation index at: https://docs.xpander.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Custom sources

> Connect your own system (an internal wiki, a ticketing system, an in-house API) to the Company Brain through a REST contract or an MCP connector. Every call carries a signed identity token naming the person who asked, so your system answers only what they may see.

A custom source puts your own system in the Company Brain without waiting for a built-in source: an internal wiki, a ticketing system, a document store behind your own API. Agents search it and read from it like any other source, with citations, scope, timeout, cache and the access log.

Your system decides what each person may see. Every call xpander makes to it carries a short-lived token, signed by your xpander install, that names the person who asked: their user id, email and groups. Your system verifies the token and answers only what that person may open, so an item it hides from someone never reaches them.

There are two ways to connect a system:

| | **REST endpoint** | **MCP server** |
| - | - | - |
| What your system exposes | `POST /search` and `POST /read` under a base URL ([the contract](#the-rest-contract)) | A search function and a read function on an MCP connector |
| Where it is configured | Its address and authentication on the source | An **MCP server** already added in the Skills registry, with the authentication set there |
| Authentication | An API key in a header you choose, each person's own OAuth sign-in, or none (the signed identity only) | Whatever the Skills registry entry uses: API key, custom headers, each person's OAuth sign-in or their SSO token |
| Signed identity | On every call | On every call |

## Add a custom source

**1. Pick Custom**

In **Platform > Agents > Knowledge**, open **Sources**, select **Add source** and pick **Custom** under **Type**.

**2. Pick the contract**

Under **Contract**, choose **REST endpoint** or **MCP server**.

For **REST endpoint**, enter the **Endpoint**: the base URL that `/search` and `/read` sit under, such as `https://kb.example.com/xpander`. Under **Authentication**, choose **API key** (enter the **Header** and the **API key**), **Each person signs in (OAuth)** (pick the **Connection** people sign in through), or **None (the signed identity only)**.

For **MCP server**, pick the server from the Skills registry and enter the names of its **Search tool** and **Read tool** (`search` and `read` by default).

**3. Name it**

Under **Name**, give the source the name people will see in citations and in the access log, then select **Add and test**.

**4. Give your system the identity values**

The dialog shows the **Audience (this source's id)**, the **JWKS URL** and the **Issuer**. Your system checks these on every call; copy them into its configuration.

**5. Test the connection**

Select **Test connection**. xpander runs a sample search as you, with your signed identity, and shows what came back or why it failed. Type a word you know is in the source under **Sample search** to test with it. Select **Done** when it works.

![Add source dialog with Type Custom, Contract REST endpoint selected, Endpoint https://kb.acme.example/xpander, Authentication API key, Header Authorization, an API key, Name Acme KB and the Add and test button](https://mintcdn.com/xpanderai-099931d1/DIcTXVn5FKmtw1je/images/use/company-brain/custom-sources-add-rest.png?fit=max\&auto=format\&n=DIcTXVn5FKmtw1je\&q=85\&s=4ade84aeabe12c686061c4a5f3e6ed25)

Add source with Custom and REST endpoint: the base URL, an API key in the Authorization header, and the name people see. Shown with sample data.

![Add source dialog with the MCP contract selected, the Acme Wiki MCP entry picked, and the default search and read function names](https://mintcdn.com/xpanderai-099931d1/DIcTXVn5FKmtw1je/images/use/company-brain/custom-sources-add-mcp.png?fit=max\&auto=format\&n=DIcTXVn5FKmtw1je\&q=85\&s=16688e449a5c1dc663f3b57660cd12e7)

Custom over an MCP connector: an entry from the Skills registry and the names of its search and read functions. Shown with sample data.

![The source is added, with copyable Audience, JWKS URL and Issuer fields, and Test connection showing Connected in 182 ms with two results](https://mintcdn.com/xpanderai-099931d1/DIcTXVn5FKmtw1je/images/use/company-brain/custom-sources-added.png?fit=max\&auto=format\&n=DIcTXVn5FKmtw1je\&q=85\&s=aab5a7069c0698c5ff89e5fa05405fa8)

After Add and test: the values your system verifies, and a Test connection that found two items. Shown with sample data.

The API key is stored encrypted and never shown again. The source's card shows only that a key is set and its last four characters; to change it, select **Edit** on the card's **Connection** tab and type a new one. Leaving the field blank keeps the stored key.

## The REST contract

Your system answers two `POST` requests under the base URL. Both take and return JSON. The full OpenAPI 3.1 description is [below](#openapi-description).

### Search

```http theme={"dark"}
POST {base}/search
X-Xpander-Identity: <signed identity token>
Authorization: Bearer <your API key>          (when the source uses an API key)
Content-Type: application/json

{"query": "what is our travel expense limit", "limit": 25, "scope": {"exclude": ["policies:drafts/"]}}
```

| Field | Meaning |
| - | - |
| `query` | The person's question as they asked it, up to 1000 characters |
| `limit` | The most items to return; xpander keeps the first 25 |
| `scope` | Optional. The admin's scope as a hint: `include` (only items under these ref prefixes or paths), `exclude` (never these) and `modified_after`. Honoring it saves work; xpander checks every item against the scope anyway |

Answer with the items this person may see, best match first:

```json theme={"dark"}
{
  "items": [
    {
      "ref": "policies:travel",
      "title": "Travel and expenses policy",
      "snippet": "Meals are reimbursed up to $50 a day when traveling.",
      "url": "https://kb.example.com/policies/travel",
      "modified": "2026-09-01T10:00:00Z",
      "path": "/Policies/Finance"
    }
  ]
}
```

| Field | Required | Meaning |
| - | - | - |
| `ref` | Yes | Your stable id for the item, up to 1000 characters. xpander passes it back to `/read`. Use `/` or `:` to nest refs (`wiki:eng/setup`) so admins can scope by ref prefix |
| `title` | Yes | Shown in the citation |
| `snippet` | No | The passage that answers the question; the answer quotes it. Up to 2000 characters |
| `url` | No | Where a person opens the item (an `http` or `https` link) |
| `modified` | No | ISO 8601 date. Needed when the source's scope has an **Older than** cut-off: items without it are left out |
| `path` | No | A `/Folder/Sub` location. Needed when the scope has path rules: items without a path are left out by a path rule |

### Read

```http theme={"dark"}
POST {base}/read
X-Xpander-Identity: <signed identity token>

{"ref": "policies:travel"}
```

```json theme={"dark"}
{
  "title": "Travel and expenses policy",
  "text": "Meals are reimbursed up to $50 a day when traveling. Hotels: up to $250 a night.",
  "url": "https://kb.example.com/policies/travel",
  "modified": "2026-09-01T10:00:00Z"
}
```

`text` is plain text or Markdown; xpander keeps the first 200,000 characters. `title`, `url`, `modified` and `path` are optional.

### Status codes

| Your answer | What xpander does |
| - | - |
| `200` | Uses the answer |
| `401` | With **Each person signs in (OAuth)**, asks that person to sign in again. Otherwise the source refused the configured key, and the person gets nothing from this source |
| `403` | This person may not use your system; they get nothing from it |
| `404` on `/read` | The item does not exist or this person may not open it. Answer the same way for both |
| Anything else, or no answer within the source's timeout | The source is skipped for this question; the answer says it could not be reached |

## The identity token

Every call carries `X-Xpander-Identity`: a JSON Web Token signed with ES256 by your xpander install. Its header names the signing key (`kid`) and has `typ: xpander-brain-identity+jwt`.

| Claim | Meaning |
| - | - |
| `sub` | The person's xpander user id |
| `email` | Their email address |
| `groups` | The names of their xpander user groups |
| `group_ids` | The ids of the same groups |
| `org` | Your xpander organization id |
| `aud` | This source's id, the **Audience** shown on the source. Reject any other |
| `iss` | The **Issuer** shown on the source |
| `iat`, `nbf`, `exp` | Issued, valid from, expires. A token lives at most 5 minutes |
| `jti` | A unique id for the token |

The public keys are at the **JWKS URL** shown on the source: `https://api.xpander.ai/.well-known/brain-identity-jwks.json` on xpander cloud, and your own API host's `/.well-known/brain-identity-jwks.json` on a self-hosted install. Cache the key set and fetch it again when a token names a `kid` you have not seen. Each install has its own signing key, and it is used for nothing but this token.

Verify the signature, `aud`, `exp` and `iss` on every call, then apply your own permissions with `sub`, `email` and `groups`.

```python Python theme={"dark"}
# pip install "pyjwt[crypto]"
import jwt

JWKS_URL = "https://api.xpander.ai/.well-known/brain-identity-jwks.json"
SOURCE_ID = "<the Audience shown on the source>"
ISSUER = "<the Issuer shown on the source>"

jwks = jwt.PyJWKClient(JWKS_URL, cache_keys=True, lifespan=300)


def person(token: str) -> dict:
    """The claims of a valid identity token; raises jwt.InvalidTokenError otherwise."""
    if jwt.get_unverified_header(token).get("typ") != "xpander-brain-identity+jwt":
        raise jwt.InvalidTokenError("not an xpander identity token")
    key = jwks.get_signing_key_from_jwt(token).key
    return jwt.decode(
        token, key, algorithms=["ES256"], audience=SOURCE_ID, issuer=ISSUER,
        leeway=30, options={"require": ["exp", "iat", "sub", "aud"]},
    )

# claims = person(request.headers["X-Xpander-Identity"])
# answer only what claims["sub"], claims["email"] and claims["groups"] may see
```

```javascript Node theme={"dark"}
// npm install jose
import { createRemoteJWKSet, jwtVerify } from "jose";

const JWKS = createRemoteJWKSet(
  new URL("https://api.xpander.ai/.well-known/brain-identity-jwks.json"),
);
const SOURCE_ID = "<the Audience shown on the source>";
const ISSUER = "<the Issuer shown on the source>";

export async function person(token) {
  const { payload } = await jwtVerify(token, JWKS, {
    algorithms: ["ES256"],
    audience: SOURCE_ID,
    issuer: ISSUER,
    typ: "xpander-brain-identity+jwt",
    clockTolerance: 30,
    requiredClaims: ["exp", "iat", "sub"],
  });
  return payload; // payload.sub, payload.email, payload.groups
}
```

### A minimal REST source

A complete source in Python (FastAPI) that verifies the token with `person()` above and filters by group:

```python theme={"dark"}
# pip install fastapi uvicorn "pyjwt[crypto]"
from fastapi import FastAPI, Header, HTTPException
from pydantic import BaseModel
import jwt

DOCS = [
    {"ref": "policies:travel", "title": "Travel and expenses policy", "allow": ["*"],
     "text": "Meals are reimbursed up to $50 a day when traveling."},
    {"ref": "finance:forecast/q4", "title": "Q4 revenue forecast", "allow": ["Finance"],
     "text": "The Q4 forecast is up 12% on Q3."},
]

app = FastAPI()


class Search(BaseModel):
    query: str
    limit: int = 25


class Read(BaseModel):
    ref: str


def caller(token: str) -> dict:
    try:
        return person(token)
    except jwt.InvalidTokenError as e:
        raise HTTPException(401, str(e))


def may_read(claims: dict, doc: dict) -> bool:
    return "*" in doc["allow"] or bool(set(doc["allow"]) & set(claims.get("groups") or []))


@app.post("/search")
def search(body: Search, x_xpander_identity: str = Header(...)):
    claims = caller(x_xpander_identity)
    words = body.query.lower().split()
    hits = [d for d in DOCS if may_read(claims, d) and any(w in (d["title"] + d["text"]).lower() for w in words)]
    return {"items": [{"ref": d["ref"], "title": d["title"], "snippet": d["text"]} for d in hits[: body.limit]]}


@app.post("/read")
def read(body: Read, x_xpander_identity: str = Header(...)):
    claims = caller(x_xpander_identity)
    doc = next((d for d in DOCS if d["ref"] == body.ref), None)
    if doc is None or not may_read(claims, doc):
        raise HTTPException(404, "not found")
    return {"title": doc["title"], "text": doc["text"]}
```

Run it behind TLS (`uvicorn app:app` behind your HTTPS proxy), use its base URL as the source's **Endpoint**, and a person outside Finance never gets the forecast.

## The MCP option

Pick an **MCP server** that is already in the Skills registry. xpander calls it over streamable HTTP: `initialize`, then `tools/call` on the **Search tool** with `{"query": "...", "limit": 25, "scope": {...}}` and on the **Read tool** with `{"ref": "..."}`.

The functions answer in the same shapes as the REST contract: the search answer is `{"items": [...]}` and the read answer is `{"title", "text", "url", "modified"}`. xpander reads `structuredContent` when the answer has it, and otherwise parses the text content as JSON; a read may also answer plain text, which becomes the item's text. A call that answers `isError` counts as a failure.

The server's authentication is the one set in the Skills registry: an API key, custom headers, each person's OAuth sign-in (each person signs in once, from the source's **Connection** tab or from **Settings > Knowledge**), or the person's SSO token. Every call also carries `X-Xpander-Identity`, so the server can apply its own permissions even with a shared key. A server that supports only the older SSE transport cannot be used as a source.

## Scope

A custom source cannot be browsed, so its scope takes typed places. On the card's **Scope** tab, choose what to include or leave out and use **Add by path**:

* **A ref prefix**, such as `wiki:eng/`. It matches every ref that starts with it at a `/` or `:` boundary: `wiki:eng/` matches `wiki:eng/setup`, not `wiki:engineering`.
* **A path**, such as `/Engineering/Runbooks`. It matches the `path` your system sends with each item. An item without a `path` is left out when the scope has a path rule.

Every item is checked against the scope on search and again on read, whatever your system answered. **Test as a user** shows which items a question would read for a chosen person, and which the scope left out.

## Settings shared with every source

The source's card has the same settings as the built-in sources: **Sensitive** (no cache), the cache time, the timeout, who can read it, and its entries in the access log. **Test connection** on the card's **Connection** tab runs a sample search as you at any time. When the source uses each person's sign-in, **Disconnect** on the **Connection** tab signs you out of it.

![Acme KB source card on the Connection tab with Contract REST endpoint, Endpoint, Authentication API key in Authorization, the Audience, JWKS URL and Issuer fields, and Test connection](https://mintcdn.com/xpanderai-099931d1/DIcTXVn5FKmtw1je/images/use/company-brain/custom-sources-card.png?fit=max\&auto=format\&n=DIcTXVn5FKmtw1je\&q=85\&s=6d183e214c540c8e46aa1e904b994f61)

A custom source's Connection tab: the contract and endpoint, the values your system verifies, and Test connection. Shown with sample data.

## Network rules

xpander calls a custom source from your install:

* **HTTPS only.** On xpander cloud the endpoint must be reachable on the public internet; private, loopback and cloud metadata addresses are refused.
* **Self-hosted installs** may call endpoints inside your network over HTTPS. Plain `http://` is accepted only when the install sets `BRAIN_CUSTOM_SOURCES_ALLOW_HTTP=true` on agent-controller, and only for an address inside your network. Metadata addresses are never called.
* **No redirects to another host.** A redirect to the same host with `307` or `308` is followed; any other redirect fails the call. Use the final URL as the endpoint.
* **Size and time.** A search answer may be up to 2 MB and a read answer up to 5 MB. A call that takes longer than the source's timeout is dropped for that question.
* The calls do not go through an `HTTPS_PROXY`.

> [!NOTE]
>
> **Air-gapped installations.** Custom sources are the live sources an air-gapped installation can use: your system lives in your network, the JWKS is served by your own xpander API, and nothing leaves the installation. **Add source** offers **Custom** there.

## Troubleshooting

![Test connection showing The source refused the credential or this person (401/403). Check the API key or sign-in, with the detail the source refused the configured credential](https://mintcdn.com/xpanderai-099931d1/DIcTXVn5FKmtw1je/images/use/company-brain/custom-sources-test-refused.png?fit=max\&auto=format\&n=DIcTXVn5FKmtw1je\&q=85\&s=c116811b2d6360356af749e9e43fbbdb)

Test connection when the source refused the API key: what failed, and the source's own words. Shown with sample data.

| Test connection says | What to check |
| - | - |
| The source refused the credential or this person (401/403) | The API key and its **Header**; that your system accepts the token's `aud` (the source's id) and `iss`; that the person's groups are allowed |
| Sign in to the source first | The source uses each person's sign-in and you have not signed in yet. Use the link, then test again |
| The source didn't answer within the timeout | Your system's response time, or raise the source's timeout |
| The source couldn't be reached or answered outside the contract | The detail line names the cause: an address that is refused (see [Network rules](#network-rules)), a redirect to another host, an answer over the size limit, an answer that is not JSON or has no `items` list, or an HTTP error |
| The source isn't fully configured | An MCP connector that left the Skills registry, a server on the SSE transport, or a missing API key: select **Edit** and fix it |

A token that fails to verify on your side is usually one of: the JWKS fetched from another install's API host, `aud` compared with something other than the source's id, or a clock more than 30 seconds off.

## OpenAPI description

> **OpenAPI 3.1 for the REST contract**
>
> ```yaml theme={"dark"}
> openapi: 3.1.0
> info:
>   title: xpander Company Brain custom source (REST contract)
>   version: 1.0.0
> paths:
>   /search:
>     post:
>       operationId: search
>       parameters:
>         - $ref: "#/components/parameters/Identity"
>       requestBody:
>         required: true
>         content:
>           application/json:
>             schema: { $ref: "#/components/schemas/SearchRequest" }
>       responses:
>         "200":
>           description: The matching items this person may see, best first
>           content:
>             application/json:
>               schema: { $ref: "#/components/schemas/SearchResponse" }
>         "401": { description: The credential is missing or expired }
>         "403": { description: This person may not search this system }
>   /read:
>     post:
>       operationId: read
>       parameters:
>         - $ref: "#/components/parameters/Identity"
>       requestBody:
>         required: true
>         content:
>           application/json:
>             schema: { $ref: "#/components/schemas/ReadRequest" }
>       responses:
>         "200":
>           description: The item's text
>           content:
>             application/json:
>               schema: { $ref: "#/components/schemas/ReadResponse" }
>         "404": { description: No such item, or this person may not open it }
>         "401": { description: The credential is missing or expired }
>         "403": { description: This person may not read this system }
> components:
>   parameters:
>     Identity:
>       name: X-Xpander-Identity
>       in: header
>       required: true
>       description: ES256 JWT naming the asking person; verify it against the JWKS on every call
>       schema: { type: string }
>   schemas:
>     SearchRequest:
>       type: object
>       required: [query, limit]
>       properties:
>         query: { type: string, maxLength: 1000 }
>         limit: { type: integer, minimum: 1, maximum: 100 }
>         scope:
>           type: object
>           properties:
>             include: { type: array, items: { type: string } }
>             exclude: { type: array, items: { type: string } }
>             modified_after: { type: string, format: date-time }
>     SearchResponse:
>       type: object
>       required: [items]
>       properties:
>         items:
>           type: array
>           maxItems: 100
>           items: { $ref: "#/components/schemas/SearchItem" }
>     SearchItem:
>       type: object
>       required: [ref, title]
>       properties:
>         ref: { type: string, maxLength: 1000 }
>         title: { type: string, maxLength: 500 }
>         snippet: { type: string, maxLength: 2000 }
>         url: { type: string, format: uri }
>         modified: { type: string, format: date-time }
>         path: { type: string, maxLength: 2000 }
>     ReadRequest:
>       type: object
>       required: [ref]
>       properties:
>         ref: { type: string, maxLength: 1000 }
>     ReadResponse:
>       type: object
>       required: [text]
>       properties:
>         title: { type: string }
>         text: { type: string }
>         url: { type: string, format: uri }
>         modified: { type: string, format: date-time }
>         path: { type: string }
> ```


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.