Integrations
OpenAI Agents SDK
Give an OpenAI agent one narrow tool onto your knowledge base, then verify its answer against the sources the tool actually returned.
The pattern is borrowed from controlled agentic commerce, where an agent may spend money but the application decides how much. Swap money for knowledge and the shape is identical — the model asks, the application authorizes, and evidence outlives the conversation.
The boundary
Everything sits on one line: what the model can see, and what only the application can see.
| Held by the application | Visible to the model |
|---|---|
| The Senso API key | The grounded answer text |
| The key's knowledge base scope | content_id and title per source |
The approved content_ids for this task | Nothing else |
| The evidence ledger of every tool return | — |
Prerequisites
- A Senso API key with KB scope (create one on the API Keys page)
- An OpenAI API key
- Python 3.10+
export SENSO_API_KEY="YOUR_SENSO_KEY"
export OPENAI_API_KEY="YOUR_OPENAI_KEY"
pip install openai-agents requests pydanticHow it works
- Scope the task — the application picks the
content_idsthis agent run is allowed to read, the way an approval grant caps a purchase - Expose one tool —
senso_lookup(question)callsPOST /org/searchand returns a grounded answer plus its citations - Record the evidence — every tool return is appended to a ledger the model cannot reach or edit
- Run the agent — the model reasons, calls the tool as many times as it needs, and returns a typed answer naming the ID of every source it relied on
- Verify after the fact — the application confirms every cited ID appears in the ledger, and rejects the answer if one does not
Step 1 — the tool
POST /org/search runs a vector query over your compiled knowledge base and returns both a synthesized answer and the results chunks behind it. The tool narrows that response to the two things the model needs, and drops everything else.
import os, requests
from pydantic import BaseModel
from agents import Agent, Runner, function_tool
BASE = "https://apiv2.senso.ai/api/v1"
HEADERS = {
"X-API-Key": os.environ["SENSO_API_KEY"],
"Content-Type": "application/json",
}
# The approval grant, in Senso terms. Leave APPROVED_CONTENT_IDS empty to let the
# agent read everything the API key's KB scope allows.
APPROVED_CONTENT_IDS: list[str] = []
class Citation(BaseModel):
content_id: str
title: str
class Evidence(BaseModel):
answer: str
citations: list[Citation]
# The ledger lives in application memory, not in the model's context. Nothing the
# model emits can add a row here — that asymmetry is what makes step 5 mean anything.
LEDGER: list[Evidence] = []
@function_tool
def senso_lookup(question: str) -> Evidence:
"""Look up a verified answer in the company knowledge base.
Use this for any factual claim about the company, its products, pricing or
policies. Never answer such a question from memory.
"""
payload: dict = {"query": question, "max_results": 5}
# require_scoped_ids is the hard edge: without it, content_ids only biases the
# search and unapproved chunks can still come back.
if APPROVED_CONTENT_IDS:
payload["content_ids"] = APPROVED_CONTENT_IDS
payload["require_scoped_ids"] = True
resp = requests.post(f"{BASE}/org/search", headers=HEADERS, json=payload)
resp.raise_for_status()
body = resp.json()
# Chunks dedupe to sources: five chunks from one document is one citation.
seen: dict[str, str] = {}
for chunk in body.get("results", []):
seen.setdefault(chunk["content_id"], chunk.get("title", "Untitled"))
evidence = Evidence(
answer=body.get("answer", ""),
citations=[Citation(content_id=cid, title=t) for cid, t in seen.items()],
)
LEDGER.append(evidence)
return evidenceA 403 here means the API key has no KB scope configured. Scope is set per key — see Permissions.Step 2 — the agent
The agent's output type is the contract. Asking for cited_content_ids as a typed field is what makes verification mechanical rather than a second LLM call.
class SupportReply(BaseModel):
reply: str
cited_content_ids: list[str]
agent = Agent(
name="Support agent",
instructions=(
"You answer customer questions about the company. "
"Call senso_lookup before making any factual claim. "
"If the knowledge base does not answer the question, say so plainly "
"rather than guessing. List the content_id of every source you relied "
"on in cited_content_ids."
),
tools=[senso_lookup],
output_type=SupportReply,
)Step 3 — verify against the ledger
def verify(reply: SupportReply, ledger: list[Evidence]) -> list[str]:
"""Return the cited IDs that no tool call ever produced."""
real = {c.content_id for e in ledger for c in e.citations}
return [cid for cid in reply.cited_content_ids if cid not in real]
result = Runner.run_sync(agent, "What is your refund window for annual plans?")
reply = result.final_output
fabricated = verify(reply, LEDGER)
if fabricated:
raise ValueError(f"Answer cites sources that were never retrieved: {fabricated}")
if not reply.cited_content_ids:
raise ValueError("Answer makes claims with no source behind it.")
print(reply.reply)
for c in {c.content_id: c for e in LEDGER for c in e.citations}.values():
print(f" source: {c.title} ({c.content_id})")Both failures are worth raising on. An answer citing a source that was never retrieved is a fabrication. An answer citing nothing at all is a model that skipped the tool — which is the more common of the two, and the quieter one.
Choosing how much the model sees
/org/search is one of four variants on the same vector query. Which one you reach for is a decision about how much reasoning you are delegating.
| Endpoint | Returns | Use when |
|---|---|---|
POST /org/search | Grounded answer plus source chunks | Senso should synthesize; the agent relays |
POST /org/search/context | Raw chunks, no answer | Your own model should synthesize |
POST /org/search/content | Source IDs and titles only | You want a citation list, not prose |
POST /org/search/stream | The same, as Server-Sent Events | Tokens should reach the user as they arrive |
/org/search/context is the honest default for an agent you have tuned yourself — you own the prompt, so you should own the synthesis. /org/search earns its place when the agent is a relay and you would rather one system be responsible for the wording.What this does not do
The ledger proves a source was retrieved. It does not prove the model read it correctly — a reply can cite a real document and still misstate what it says. Closing that gap is a different mechanism: run the draft through content verification before anything reaches a customer, where a human or a policy approves the version itself rather than its footnotes.
Where to take it next
- Swap
Runner.run_syncforRunner.runand stream from/org/search/stream - Skip the tool wiring entirely and point a client at the MCP Server, which exposes the same search surface over MCP
- Package the pattern as a reusable agent skill
- Read Core Concepts for what "compiled" means and why an uncompiled source returns nothing
