Senso
Sign inSign up

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.

An agent that answers customer questions is only as trustworthy as the last sentence it wrote. The usual fix is to hand the model more context and hope. This recipe does the opposite: the agent gets one narrow tool, the application keeps the credential and the record of what that tool returned, and the model's final answer is checked against that record after it responds.

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 applicationVisible to the model
The Senso API keyThe grounded answer text
The key's knowledge base scopecontent_id and title per source
The approved content_ids for this taskNothing else
The evidence ledger of every tool return
The model never receives the key, never chooses which knowledge base to read, and cannot widen its own scope. It asks a question in English; the application decides what that question is allowed to reach.

Prerequisites

  • A Senso API key with KB scope (create one on the API Keys page)
  • An OpenAI API key
  • Python 3.10+
bash
export SENSO_API_KEY="YOUR_SENSO_KEY"
export OPENAI_API_KEY="YOUR_OPENAI_KEY"
pip install openai-agents requests pydantic

How it works

  1. Scope the task — the application picks the content_ids this agent run is allowed to read, the way an approval grant caps a purchase
  2. Expose one toolsenso_lookup(question) calls POST /org/search and returns a grounded answer plus its citations
  3. Record the evidence — every tool return is appended to a ledger the model cannot reach or edit
  4. 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
  5. Verify after the fact — the application confirms every cited ID appears in the ledger, and rejects the answer if one does not
Step 5 is the whole point. A model can write a plausible UUID. It cannot make one appear in a ledger it never had access to.

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.

python
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 evidence

A 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.

python
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

python
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.

EndpointReturnsUse when
POST /org/searchGrounded answer plus source chunksSenso should synthesize; the agent relays
POST /org/search/contextRaw chunks, no answerYour own model should synthesize
POST /org/search/contentSource IDs and titles onlyYou want a citation list, not prose
POST /org/search/streamThe same, as Server-Sent EventsTokens 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_sync for Runner.run and 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