Skip to main content

qunivex

Official Python SDK for the Qunivex API — run your AI agents, search your knowledge base, and manage your projects, agents, tools, guardrails and documents from Python.

pip install qunivex
from qunivex import Qunivex

qx = Qunivex(api_key="qvx-live-...")

print(qx.chat("QXA-8MQ7KN2A", "What plans do you offer?").content)

Create a key on the API page in your dashboard. Full reference: qunivex.com/docs/api


model is an agent, not a foundation model

This is the one thing to know before you start. A Qunivex agent is a whole configured pipeline — system prompt, knowledge base, tools, MCP servers, sub-agents, guardrails, workflow — built in your dashboard. Which LLM runs inside it is the agent's own setting, not something the caller picks.

So model takes a QXA- agent id. A QXP- project id also works and resolves to that project's main agent.

Prefix Names Example
QXP- Project QXP-1DM2J8K0
QXA- Agent (main or sub-agent) QXA-8MQ7KN2A

Both are on your project's Overview page.

Setup

qx = Qunivex(api_key="qvx-live-...")

Or set QUNIVEX_API_KEY in the environment and pass nothing — worth preferring, since a key in source is a key in version control.

qx = Qunivex()                                  # reads QUNIVEX_API_KEY
qx = Qunivex(timeout=60, max_retries=4)         # tune transport
with Qunivex() as qx: ...                        # closes the HTTP session

Rate limits and transient server errors are retried automatically with exponential backoff and jitter, honouring Retry-After. A quota 429 is not retried — waiting will not help.

Chat

reply = qx.chat("QXA-8MQ7KN2A", "What plans do you offer?")

reply.content            # the text
reply.total_tokens       # usage for this turn
reply.blocked            # True if a guardrail stopped it
reply.raw                # the untouched API payload

Stream it:

for piece in qx.stream("QXA-8MQ7KN2A", "Tell me about Pro"):
    print(piece, end="", flush=True)

Multi-turn

The API is stateless — every request carries the full history, exactly like OpenAI's. That makes each call reproducible and leaves the context window under your control.

Manage the transcript yourself:

history = [{"role": "user", "content": "What plans do you offer?"}]
r = qx.chat(AGENT, history[-1]["content"])
history.append({"role": "assistant", "content": r.content})

…or let a Conversation do it:

convo = qx.conversation("QXA-8MQ7KN2A", system="Be concise.")

convo.send("What plans do you offer?")
convo.send("Which suits two people?")     # remembers the first turn

convo.messages          # the running transcript — mutable, trim it if you like
convo.reset()           # start over, keeping the system message

A Conversation also generates one conversation_id and sends it with every turn, so the whole exchange is grouped together in your dashboard's Logs.

Your own functions

Your agent's own tools — web search, custom tools, MCP servers — run on our side as part of the turn. This is the other kind: a function that only exists in your code. Looking up an order in your database, charging a card, checking stock.

Declare it with tools exactly as you would with OpenAI. When the model calls it, the turn stops and hands you the call:

GET_ORDER = {"type": "function", "function": {
    "name": "get_order",
    "description": "Look up an order by its number.",
    "parameters": {"type": "object",
                   "properties": {"order_id": {"type": "string"}},
                   "required": ["order_id"]},
}}

reply = qx.chat(AGENT, "Where is order A-4471?", tools=[GET_ORDER])

if reply.needs_tools:
    call = reply.tool_calls[0]
    print(call.name, call.arguments)      # get_order {'order_id': 'A-4471'}

Run it, hand the result back, and the model answers with it:

messages = [{"role": "user", "content": "Where is order A-4471?"}]
reply = qx.chat(AGENT, messages, tools=[GET_ORDER])

while reply.needs_tools:
    messages.append(reply.message)                 # the assistant's request
    for call in reply.tool_calls:
        messages.append(call.result(my_lookup(**call.arguments)))
    reply = qx.chat(AGENT, None, history=messages, tools=[GET_ORDER])

print(reply.content)

Or skip the loop entirely — give a Conversation some handlers:

convo = qx.conversation(AGENT, tools=[GET_ORDER],
                        handlers={"get_order": my_lookup})

print(convo.send("Where is order A-4471?").content)

A handler that raises is reported back to the model rather than propagating — it can recover by answering without the tool, and raising would strand a transcript holding an unanswered call. The loop is bounded by max_tool_rounds (default 5).

We never run your function. You send a schema, not code; the call comes back to you and the turn ends there.

Raw OpenAI shape

When you need a field the wrappers do not surface:

resp = qx.completions.create(
    model="QXA-8MQ7KN2A",
    messages=[{"role": "user", "content": "Hello"}],
    temperature=0.2,
)
resp["choices"][0]["message"]["content"]

Semantic search

Retrieval only — no model call, so it is far cheaper and faster than a completion. Ideal for adding search to your own site over content already in Qunivex.

for hit in qx.search("QXP-1DM2J8K0", "refund policy", top_k=3):
    print(round(hit.score, 3), hit.source, hit.text[:100])

score is a relevance figure in (0, 1] — higher is better. Pass agent= to search a sub-agent's own knowledge base instead of the project's.

If the embedding provider is down this raises ServiceUnavailableError rather than returning an empty list, so "nothing matched" and "search is broken" never look the same.

Reads are properties, not calls

qx.models, qx.usage, qx.tools, project.documents, reply.blocked, doc.ready — no parentheses. Parentheses in this SDK mean the call does work you might want to control: .list(limit=…), .upload(path), .create(…).

Projects

for p in qx.projects:                  # iterating pages automatically
    print(p.id, p.name)

page = qx.projects.list(limit=10)      # one page: a list, plus .has_more
everything = qx.projects.all           # all of them, as a list

project = qx.projects.create("Acme Support", website_url="https://acme.com")
project = qx.projects.retrieve("QXP-1DM2J8K0")

project.name                        # 'Acme Support'
project.main_agent.system_prompt
project.sub_agents                  # [Agent, ...]
project.knowledge.documents         # 12

project.update(
    name="Acme Support",
    main_agent={"system_prompt": "Be concise and always cite a source.",
                "temperature": 0.3},
)

project.chat("What plans do you offer?")     # via the main agent
project.search("refund policy")
project.delete()                             # not recoverable

create gives you a working project in one call: a main agent with a widget key already minted, the default workflow wired up, and a knowledge base ready to fill. Optional fields: model, agent_name, system_prompt, temperature, max_tokens, website_url, website_name, website_description, target_audience, tone_style, routes, widget_enabled.

Every list resource follows the same shape — .list(limit, offset), .all, and plain iteration.

Agents

A project has one main agent (what your widget talks to) and any number of sub-agents it can delegate to.

for a in project.agents:
    print(a.id, a.name, "main" if a.is_main_agent else "sub")

billing = project.agents.create(
    "Billing",
    description="Invoices, payment methods, refunds and plan changes.",
    system_prompt="You answer billing questions precisely.",
)

project.main_agent.update(
    system_prompt="Be concise and always cite a source.",
    tools={"builtin": ["web_search"], "custom": [tool.id]},
    sub_agents=[billing.id],
    guardrails=[rail.id],
    allowed_domains=["acme.com"],
)

billing.chat("How do refunds work?")
billing.delete()

description matters more than it looks: the main agent reads it to decide when a question belongs to this specialist.

The capability lists (builtin_tools, custom_tools, mcp_servers, sub_agents, guardrails) come from the agent's saved workflow, so what you read is what will actually run. Setting a non-empty sub_agents list turns delegation on.

Knowledge base

A knowledge base holds two kinds of thing: documents you upload, and the Website Info Qunivex writes by reading your site. Both are embedded into the same store and retrieved the same way.

docs = project.documents

docs.upload("handbook.pdf")
docs.add_text("faq.txt", "Q: Do you ship internationally? A: Yes…")

for d in docs.list():
    print(d.filename, d.status, d.chunks, d.tokens)

docs.delete(doc_id)

Indexing happens before the response returns, so the Document you get back already carries its final status and chunk count — nothing to poll. Supported: PDF, DOCX, TXT, MD, HTML, JSON, XML, RTF, YAML and other plain-text formats.

For a file whose extraction might outlive your HTTP timeout, use wait=False and poll:

doc = docs.upload("500-page-manual.pdf", wait=False)
while doc.processing:
    time.sleep(2)
    doc = docs.retrieve(doc.id)

Uploads count against your plan's knowledge-base token budget.

The AI-fetched docs

Point Qunivex at your site and it reads the pages you choose, writing a factual knowledge-base section from each. For most agents this ends up being the largest single thing they know.

info = project.website_info

info.generate(website_url="https://acme.com", routes=["/", "/pricing", "/faq"])
info.status                 # 'generating' → 'ready'

# …or block until it finishes
info.generate(routes=["/", "/pricing"], wait=True)
print(info.content)

info.set("# Acme\n\nAcme sells widgets.")   # write your own instead
info.clear()

It is not a file, so it does not appear in documents.list() by default. Ask for it explicitly:

for d in docs.list(include_website_info=True):
    print(d.kind, d.filename, d.tokens)     # 'website_info' / 'file'

At most 5 routes are read per run; extra ones are ignored rather than rejected.

Sub-agent knowledge bases

Every sub-agent has its own, separate from the project's:

billing.documents.upload("invoicing-policy.pdf")
billing.website_info.set("# Billing\n\nWe bill monthly in arrears.")

qx.search(project.id, "when are invoices sent", agent=billing.id)

Tools, guardrails & MCP

What an agent can do. Create these on the project, then switch them on for an agent — creating one does not enable it anywhere, which is what lets you build and test before anything goes live.

for t in qx.tools:                       # the built-in tools we ship
    print(t.name, "—", t.description)

# A tool you write, run on our side. Unlike your own functions above, this is
# part of the agent everywhere it is deployed — including your website widget,
# where there is no code of yours to call.
stock = project.tools.create(
    "check-stock", type="http",
    description="Checks whether a product is in stock.",
    config={"method": "GET",
            "url": "https://api.acme.com/stock/{{sku}}",
            "parameters": [{"name": "sku", "type": "string", "required": True}]},
)

rail = project.guardrails.create(
    "No card numbers", subtype="regex",
    position="input", action="block",
    message="Please don't share card details here.",
    config={"subtype": "regex", "pattern": r"\b(?:\d[ -]*?){13,16}\b"},
)

server = project.mcp_servers.create(
    "Internal Docs", "https://mcp.acme.com/v1",
    transport="http", auth_type="bearer", auth_token="…",
)
print(server.connected, server.tool_count)

project.main_agent.update(tools={"custom": [stock.id], "mcp_servers": [server.id]},
                          guardrails=[rail.id])

A blocked message comes back as a normal reply with reply.blocked set, carrying the guardrail's message — not an error.

MCP credentials are write-only: auth_token and custom headers are accepted but never read back. server.has_auth tells you whether one is stored, and omitting auth_token on an update keeps the existing one. project.mcp_servers.refresh(id) re-discovers a server's tools, which is needed when a server adds one — specs are cached so chat opens no connection to build them.

Activity logs

Every chat turn, tool call and error your agents produced — the same record the dashboard's Logs page reads.

for row in project.logs.list(source="public", type="chat", limit=20):
    print(row.created_at, row.total_tokens, row.summary)

for row in project.logs.conversation("conv-7f21"):   # oldest first
    print(row.type, row.summary)

Filters: source (public / test / api, or a list), type, status, agent, conversation_id, since (a string or a datetime).

tokens_input counts the whole prompt the model saw — system prompt, tool schemas, retrieved knowledge, history — not just the message, so it is usually far larger than tokens_output.

Discovery & usage

for m in qx.models:
    print(m.id, m.name, m.project_name, m.model_name)

u = qx.usage
print(f"{u.api_calls_used}/{u.api_calls_limit} calls, {u.api_calls_remaining} left")

qx.rate_limit
# {'limit': 120, 'remaining': 118, 'reset': 43,
#  'quota_limit': 25000, 'quota_remaining': 24870}

models, tools and usage do not count against your quota. rate_limit reflects the last response, so you can pace ahead of the burst limit instead of discovering it by being refused — the two budgets are on different clocks, and waiting only helps with the burst one.

Errors

from qunivex import (
    QunivexError, AuthenticationError, PermissionDeniedError,
    NotFoundError, RateLimitError, QuotaExceededError,
)

try:
    qx.chat(AGENT, "hello")
except QuotaExceededError:
    ...                     # monthly allowance spent — upgrade or wait
except RateLimitError as e:
    ...                     # burst limit; e.retry_after has the wait
except AuthenticationError:
    ...                     # bad or revoked key
except QunivexError as e:
    print(e.status, e.code, e.message)

Every error subclasses QunivexError, so that last clause is a complete catch.

NotFoundError is also what you get for a project this key was scoped away from — the API does not distinguish the two, so key scoping cannot be used to probe what sits behind it.

ConflictError (409) means the resource exists but is in the wrong state — a disabled agent, or an attempt to delete a project's main agent.

Upgrading from 1.x

Three breaking changes, all in the same direction — reads became properties:

1.x 2.x
qx.models() qx.models
qx.usage() qx.usage
AgentInfo Agent (the old name still imports and is the same class)

Everything else is additive: .list() now returns a Page that is still a plain list, and project.documents.list() returns the same documents it always did unless you ask for include_website_info=True.

Keys & security

  • Keys look like qvx-live- plus 28 characters, and the full value is shown once, when you create it. Only a hash is stored, so it cannot be recovered — lose it, revoke it, mint a new one.
  • A key belongs to your account, and can optionally be narrowed to specific projects (or to none) when you create or edit it.
  • A key carries the full rights of your account within its scope, including editing an agent's prompt and deleting documents. Never ship one to a browser, a mobile app, or a public repo. For a chat widget on a website, use the embed snippet on your project's Deployments page — that uses a separate, domain-locked public key designed to be visible.

Requirements

Python 3.8+ and requests. That is the whole dependency list.

License

MIT

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

qunivex-2.0.0.tar.gz (39.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

qunivex-2.0.0-py3-none-any.whl (34.9 kB view details)

Uploaded Python 3

File details

Details for the file qunivex-2.0.0.tar.gz.

File metadata

  • Download URL: qunivex-2.0.0.tar.gz
  • Upload date:
  • Size: 39.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.14.5

File hashes

Hashes for qunivex-2.0.0.tar.gz
Algorithm Hash digest
SHA256 e08b8e569ca69a68e91078bd5b79ddc370babe2470e218687050daaa755e62f3
MD5 754ce62c33267711d5a868a7006e4f00
BLAKE2b-256 38cb2bd24f629010f40a917313abf16865a17973e958852b040899f861f0994c

See more details on using hashes here.

File details

Details for the file qunivex-2.0.0-py3-none-any.whl.

File metadata

  • Download URL: qunivex-2.0.0-py3-none-any.whl
  • Upload date:
  • Size: 34.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.14.5

File hashes

Hashes for qunivex-2.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 17499028f1be18c5c0d2dbd5128c4d506d8df721542c299b6a62ee6e0b825c93
MD5 fd48840e4c6c85bb3b68f799c13c40c8
BLAKE2b-256 258bbb7b34ff82feda42a30ac6f48589f8718ba1c2e876bc7ba47993bc16682f

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

2.0.0 This release

2 files

1.0.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page