Qunivex
Language
Log In
API Reference
On this page
  • 01Getting started
  • 02Authentication
  • 03Ids & the model field
  • 04Chat completions
  • 05Your own functions
  • 06Semantic search
  • 07Projects
  • 08Agents
  • 09Knowledge base
  • 10Tools, guardrails & MCP
  • 11Activity logs
  • 12Models & usage
  • 13Python SDK
  • 14Limits & errors
Qunivex.com · API · v1.52.0.1
API

Qunivex API Reference

Run your agents, search your knowledge base and manage your projects from your own code — through an OpenAI-compatible endpoint or the qunivex Python SDK.

Base URL · https://qunivex.com/v1

1Getting started

Create a key on the API page in your dashboard, then make your first call. Three lines is the whole setup — pick your client at the top right of any example on this page, it stays picked as you read on:

pip install qunivex
from qunivex import Qunivex

qx = Qunivex(api_key="qvx-live-...")
print(qx.chat("QXA-8MQ7KN2A", "What plans do you offer?").content)
pip install openai
from openai import OpenAI

client = OpenAI(api_key="qvx-live-...", base_url="https://qunivex.com/v1")
resp = client.chat.completions.create(
    model="QXA-8MQ7KN2A",
    messages=[{"role": "user", "content": "What plans do you offer?"}],
)
print(resp.choices[0].message.content)
curl https://qunivex.com/v1/chat/completions \
  -H "Authorization: Bearer qvx-live-..." \
  -H "Content-Type: application/json" \
  -d '{
    "model": "QXA-8MQ7KN2A",
    "messages": [{"role": "user", "content": "What plans do you offer?"}]
  }'

The OpenAI and cURL tabs both speak the endpoint's OpenAI-compatible wire format directly — no SDK required if you already have an openai client wired up somewhere.

The API is a paid feature. Starter includes 5,000 calls per month and 5 keys; Pro includes 25,000 calls per month and 25 keys. Free Trial accounts have no API access — see Plans.

2Authentication

Every request carries your key as a bearer token:

Authorization: Bearer qvx-live-7KQ2M8NJ4RXA9DP3WY6TH5S1

An X-Api-Key: <key> header is accepted as an alternative if a bearer header is awkward in your environment. Keys look like qvx-live- followed by 28 characters.

Keys are shown once

We store only a SHA-256 hash of your key, so the full value appears exactly once — at the moment you create it. After that the dashboard shows only the first few characters. If you lose a key, revoke it and create a new one; there is no way to recover the original.

Project scope

A key belongs to your account, not to one project, which is what lets it list and resolve projects at all. You can optionally narrow a key to specific projects when you create or edit it. A key that has been scoped away from a project gets a 404 for it — not a 403 — so the scoping does not confirm what exists behind it.

Selecting no projects is a valid choice: the key authenticates and can read account-level metadata, but reaches no project data.

Treat a key like a password. It carries the full rights of your account within its project scope, including changing an agent's system prompt and deleting knowledge-base documents. Never ship one to a browser, a mobile app, or a public repository. For a chat widget on a website, use the embed snippet on your project's Deployments page instead — that uses a separate, domain-locked public key designed to be visible.

3Ids & the model field

Everything is addressed by an opaque public id. You will see these on your project's Overview page and throughout the dashboard:

PrefixWhat it namesExample
QXP-ProjectQXP-1DM2J8K0
QXA-Agent (main or sub-agent)QXA-8MQ7KN2A
model names an agent, not a foundation model. This is the one place our API deliberately departs from OpenAI's meaning. A Qunivex agent is a whole configured pipeline — system prompt, knowledge base, tools, sub-agents, guardrails and workflow — and which LLM runs inside it is the agent's own setting, chosen in your dashboard. So model takes a QXA- agent id. Passing a QXP- project id also works and resolves to that project's main agent.

Ids are case-insensitive and use an alphabet with the look-alike characters I, L, O and U removed, so a misread id fails cleanly rather than resolving to something else.

4Chat completions

POST /v1/chat/completions

Runs one turn of an agent. Accepts and returns OpenAI's chat.completion shapes, with streaming supported.

reply = qx.chat("QXA-8MQ7KN2A", "What plans do you offer?", temperature=0.3)
print(reply.content, reply.total_tokens)
resp = client.chat.completions.create(
    model="QXA-8MQ7KN2A",
    messages=[{"role": "user", "content": "What plans do you offer?"}],
    temperature=0.3,
)
print(resp.choices[0].message.content)
curl https://qunivex.com/v1/chat/completions \
  -H "Authorization: Bearer qvx-live-..." \
  -H "Content-Type: application/json" \
  -d '{
    "model": "QXA-8MQ7KN2A",
    "messages": [{"role": "user", "content": "What plans do you offer?"}],
    "temperature": 0.3
  }'

Request

FieldTypeNotes
modelstringRequired. An agent id (QXA-…) or a project id (QXP-…).
messagesarrayRequired. Roles system, user, assistant. The last message must be a user one.
streambooleanServer-sent events, terminated by data: [DONE]. Default false.
temperaturenumber0–2. Defaults to the agent's own setting.
max_tokensintegerDefaults to the agent's own setting.
conversation_idstringOptional. Groups turns together in your dashboard's Logs and Analytics. Does not affect the model's context — see below.

Conversations are stateless

The API stores no conversation history between calls. Like OpenAI's, it is stateless by design: you send the full messages array every time, and you decide what stays in it. That makes each request completely reproducible and puts you in control of the context window.

conversation_id is a label, not a memory handle. Pass the same string across a series of calls and they will be grouped as one conversation in your dashboard's Logs and Analytics pages, which is how you find a specific exchange later. If you omit it, each call is logged on its own.

AGENT = "QXA-8MQ7KN2A"
messages = [{"role": "user", "content": "What plans do you offer?"}]
r1 = qx.chat(AGENT, messages)

# Append the reply, then your next question — this is the history.
messages.append({"role": "assistant", "content": r1.content})
messages.append({"role": "user", "content": "Which is best for two people?"})
r2 = qx.chat(AGENT, messages)
AGENT = "QXA-8MQ7KN2A"
messages = [{"role": "user", "content": "What plans do you offer?"}]
r1 = client.chat.completions.create(model=AGENT, messages=messages)

# Append the reply, then your next question — this is the history.
messages.append({"role": "assistant", "content": r1.choices[0].message.content})
messages.append({"role": "user", "content": "Which is best for two people?"})
r2 = client.chat.completions.create(model=AGENT, messages=messages)
curl https://qunivex.com/v1/chat/completions \
  -H "Authorization: Bearer qvx-live-..." -H "Content-Type: application/json" \
  -d '{"model": "QXA-8MQ7KN2A",
       "messages": [{"role": "user", "content": "What plans do you offer?"}]}'

# Append the reply, then your next question, and send the whole array again.
curl https://qunivex.com/v1/chat/completions \
  -H "Authorization: Bearer qvx-live-..." -H "Content-Type: application/json" \
  -d '{"model": "QXA-8MQ7KN2A",
       "messages": [
         {"role": "user", "content": "What plans do you offer?"},
         {"role": "assistant", "content": "We offer three plans…"},
         {"role": "user", "content": "Which is best for two people?"}
       ]}'

How much of that history the model actually sees is capped by your plan's context depth; older turns are dropped from the prompt, oldest first.

Response

{
  "id": "chatcmpl-4f9a2c1b8e7d6a5f3b2c1d0e",
  "object": "chat.completion",
  "created": 1775394000,
  "model": "QXA-8MQ7KN2A",
  "choices": [{
    "index": 0,
    "message": {"role": "assistant", "content": "We offer three plans…"},
    "finish_reason": "stop"
  }],
  "usage": {"prompt_tokens": 812, "completion_tokens": 96, "total_tokens": 908}
}

When a guardrail blocks a message, you get a normal completion whose finish_reason is content_filter and whose content is the refusal text configured on that guardrail — not an error status.

Agents whose model produces reasoning traces stream them in a reasoning_content field on the delta. Standard OpenAI clients ignore it; the Qunivex SDK exposes it.

Everything the agent is configured to do applies here exactly as it does on your website widget: knowledge-base retrieval, built-in and custom tools, MCP servers, sub-agent delegation, and guardrails.

Streaming

Set stream: true for server-sent events — each chunk carries a delta the same shape OpenAI's does, and the stream ends with a literal data: [DONE] line.

for piece in qx.stream("QXA-8MQ7KN2A", "Tell me about Pro"):
    print(piece, end="", flush=True)
stream = client.chat.completions.create(
    model="QXA-8MQ7KN2A",
    messages=[{"role": "user", "content": "Tell me about Pro"}],
    stream=True,
)
for chunk in stream:
    delta = chunk.choices[0].delta.content
    if delta:
        print(delta, end="", flush=True)
curl https://qunivex.com/v1/chat/completions \
  -H "Authorization: Bearer qvx-live-..." -H "Content-Type: application/json" \
  -d '{"model": "QXA-8MQ7KN2A",
       "messages": [{"role": "user", "content": "Tell me about Pro"}],
       "stream": true}'

# data: {"choices":[{"delta":{"content":"Pro"}}]}
# data: {"choices":[{"delta":{"content":" is..."}}]}
# data: [DONE]

5Your own functions

Your agent already has whatever tools you gave it in the dashboard — web search, your own custom tools, MCP servers — and those run on our side, invisibly, as part of the turn. This section is about the other kind: a function that lives in your code and can only run there. 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 decides to call it, the turn stops and hands you the call instead of an answer:

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("QXA-8MQ7KN2A", "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'}
resp = client.chat.completions.create(
    model="QXA-8MQ7KN2A",
    messages=[{"role": "user", "content": "Where is order A-4471?"}],
    tools=[GET_ORDER],
)
call = resp.choices[0].message.tool_calls[0]
print(call.function.name, call.function.arguments)
curl https://qunivex.com/v1/chat/completions \
  -H "Authorization: Bearer qvx-live-..." -H "Content-Type: application/json" \
  -d '{
    "model": "QXA-8MQ7KN2A",
    "messages": [{"role": "user", "content": "Where is order A-4471?"}],
    "tools": [{"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"]}}}]
  }'

The reply's finish_reason is tool_calls, its content is empty, and message.tool_calls holds what to run. Run it, append the result as a tool message, and send the whole array again — the model then answers with the result in hand:

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:
        result = my_lookup(**call.arguments)        # your code, your data
        messages.append(call.result(result))        # the tool message
    reply = qx.chat(AGENT, None, history=messages, tools=[GET_ORDER])

print(reply.content)      # "Order A-4471 shipped on Tuesday…"
curl https://qunivex.com/v1/chat/completions \
  -H "Authorization: Bearer qvx-live-..." -H "Content-Type: application/json" \
  -d '{
    "model": "QXA-8MQ7KN2A",
    "tools": [ ... ],
    "messages": [
      {"role": "user", "content": "Where is order A-4471?"},
      {"role": "assistant", "content": null, "tool_calls": [
        {"id": "call_1", "type": "function",
         "function": {"name": "get_order", "arguments": "{\"order_id\":\"A-4471\"}"}}]},
      {"role": "tool", "tool_call_id": "call_1",
       "content": "{\"status\":\"shipped\",\"date\":\"2026-08-04\"}"}
    ]
  }'

A tool message must carry the tool_call_id of the call it answers, and the assistant message holding those calls has to stay in the array — an orphaned result is rejected. If you would rather not write that loop, the SDK's Conversation does it for you:

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

print(convo.send("Where is order A-4471?").content)   # runs the function for you
We never run your function. You send us a schema, not code. The model decides a call is needed, we hand it back, and the turn ends there — nothing about your function executes on our infrastructure. Everything else the agent does that turn (retrieval, its own tools, guardrails) has already happened normally.

At most 64 functions per request, and only type: "function" — provider-hosted tool types have nothing here to host them. tool_choice: "none" declares them without offering them.

6Semantic search

POST /v1/search

Searches a project's knowledge base and returns the matching passages. No model call is involved — this is retrieval only, which makes it far cheaper and faster than a chat completion. Use it to add semantic search to your own site or app over content you have already uploaded or crawled into Qunivex.

FieldTypeNotes
projectstringRequired. A QXP- project id.
querystringRequired. Natural language; it is embedded, not keyword-matched.
top_kinteger1–20, default 5.
agentstringOptional. A sub-agent id, to search that sub-agent's own knowledge base instead of the project's.
for hit in qx.search("QXP-1DM2J8K0", "refund policy", top_k=3):
    print(round(hit.score, 3), hit.source, hit.text[:100])
curl https://qunivex.com/v1/search \
  -H "Authorization: Bearer qvx-live-..." \
  -H "Content-Type: application/json" \
  -d '{"project": "QXP-1DM2J8K0", "query": "refund policy", "top_k": 3}'

Response:

{
  "object": "list",
  "query": "refund policy",
  "project": "QXP-1DM2J8K0",
  "data": [
    {"object": "search_result", "index": 0, "text": "Refunds are issued…",
     "score": 0.8134, "source": "terms.pdf", "chunk": 4}
  ]
}

score is a relevance figure in (0, 1] — higher is more relevant. If the embedding provider is unavailable this returns 503 rather than an empty result list, so you can tell "nothing matched" apart from "search is down".

7Projects

Lists & pagination

Every list endpoint on this page takes limit (1–100, default 50) and offset, and returns them back alongside has_more:

{"object": "list", "data": [...], "limit": 50, "offset": 0, "has_more": true}

In the SDK, .list() returns a page that behaves as a plain list with .has_more on it, and iterating the resource walks every page for you:

page = qx.projects.list(limit=10)          # one page
for p in qx.projects: ...                  # all of them, lazily
everything = qx.projects.all               # all of them, as a list

GET /v1/projects

Lists every project this key can reach.

for p in qx.projects:
    print(p.id, p.name)
curl "https://qunivex.com/v1/projects?limit=10" -H "Authorization: Bearer qvx-live-..."

POST /v1/projects

Creates a project and its main agent in one call — with a widget key already minted, the default workflow wired up, and the knowledge base ready to fill. name is the only required field.

project = qx.projects.create(
    "Acme Support",
    website_url="https://acme.com",
    system_prompt="You answer questions about Acme's products.",
)
print(project.id, project.main_agent.id)
curl -X POST https://qunivex.com/v1/projects \
  -H "Authorization: Bearer qvx-live-..." -H "Content-Type: application/json" \
  -d '{"name": "Acme Support", "website_url": "https://acme.com",
       "system_prompt": "You answer questions about Acme'\''s products."}'
FieldNotes
nameRequired.
descriptionYour own note; not shown to the model.
modelDefaults to your plan's default model.
agent_name, system_prompt, temperature, max_tokensApplied to the main agent.
website_urlAlso whitelisted for the chat widget, so the embed works straight away.
website_name, website_description, target_audience, tone_styleBusiness context the agent is given about you.
routesPages to read later with Website Info.
widget_enabledDefault true.

Counts against your plan's project limit. A key scoped to specific projects cannot create one — the new project would fall outside its own scope, so the very next call could not see what it just made.

GET /v1/projects/{id}

Full detail for one project: its main agent's configuration, its sub-agents, and knowledge-base counts.

project = qx.projects.retrieve("QXP-1DM2J8K0")
print(project.name, project.main_agent.model)
curl https://qunivex.com/v1/projects/QXP-1DM2J8K0 -H "Authorization: Bearer qvx-live-..."

Response:

{
  "object": "project",
  "id": "QXP-1DM2J8K0",
  "name": "Acme Support",
  "description": "",
  "created_at": "2026-03-04T11:22:31Z",
  "main_agent": {
    "id": "QXA-8MQ7KN2A",
    "name": "Acme Assistant",
    "model": "meta-llama/Llama-3.3-70B-Instruct-Turbo",
    "system_prompt": "You are…",
    "temperature": 0.7,
    "max_tokens": 2048,
    "memory": true,
    "widget_enabled": true
  },
  "sub_agents": [...],
  "knowledge": {
    "website_name": "Acme", "website_url": "https://acme.com",
    "documents": 12, "chunks": 486,
    "website_info": {"status": "ready", "tokens": 4180, "routes": ["/", "/pricing"]}
  },
  "counts": {"sub_agents": 2, "custom_tools": 3, "mcp_servers": 1, "guardrails": 2}
}

main_agent and each entry of sub_agents are the same object shape the agent endpoints return, so an agent read here needs no second fetch to be usable.

PATCH /v1/projects/{id}

Updates a project and/or its main agent. Send back the same shape GET returns — only the fields you include are changed.

project.update(
    name="Acme Support",
    main_agent={"system_prompt": "Be concise and always cite a source.",
                "temperature": 0.3},
)
curl -X PATCH https://qunivex.com/v1/projects/QXP-1DM2J8K0 \
  -H "Authorization: Bearer qvx-live-..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Acme Support",
    "main_agent": {"system_prompt": "Be concise and always cite a source.",
                   "temperature": 0.3}
  }'

Editable at the top level: name, description. Under main_agent: name, system_prompt, model, temperature, max_tokens, memory, widget_enabled. For the rest of an agent's settings — its tools, sub-agents and guardrails — use the agent endpoint.

model is validated against the catalog your plan allows, so a typo is rejected here rather than failing on every later chat.

DELETE /v1/projects/{id}

Deletes the project and everything in it — agents, documents, embeddings, custom tools, guardrails, MCP servers and logs.

project.delete()
qx.projects.delete("QXP-1DM2J8K0")     # or without fetching it first
curl -X DELETE https://qunivex.com/v1/projects/QXP-1DM2J8K0 \
  -H "Authorization: Bearer qvx-live-..."
Not recoverable. There is no undo and no trash. Any widget embedded on your site stops working the moment the project is gone.

8Agents

A project has one main agent — the one your widget talks to and the one a QXP- id resolves to — and any number of sub-agents, specialists the main agent can delegate to. Both are addressed by a QXA- id and both are edited here.

GET /v1/projects/{id}/agents

Every agent in a project, main agent first, including disabled ones.

for a in project.agents:
    print(a.id, a.name, "main" if a.is_main_agent else "sub")
curl https://qunivex.com/v1/projects/QXP-1DM2J8K0/agents \
  -H "Authorization: Bearer qvx-live-..."

POST /v1/projects/{id}/agents

Creates a sub-agent. description matters more than it looks: the main agent reads it to decide when a question belongs to this specialist, so write it for that audience.

billing = project.agents.create(
    "Billing",
    description="Invoices, payment methods, refunds and plan changes.",
    system_prompt="You answer billing questions precisely and never guess amounts.",
)
curl -X POST https://qunivex.com/v1/projects/QXP-1DM2J8K0/agents \
  -H "Authorization: Bearer qvx-live-..." -H "Content-Type: application/json" \
  -d '{"name": "Billing",
       "description": "Invoices, payment methods, refunds and plan changes."}'

Accepts name (required), description, model, system_prompt, temperature, max_tokens, top_p. Counts against your plan's sub-agents-per-project limit. Only sub-agents can be created — the main agent comes with the project.

GET /v1/agents/{id}

One agent in full, including what it is allowed to do:

{
  "object": "agent",
  "id": "QXA-8MQ7KN2A",
  "project": "QXP-1DM2J8K0",
  "name": "Acme Assistant",
  "model": "meta-llama/Llama-3.3-70B-Instruct-Turbo",
  "is_main_agent": true,
  "is_active": true,
  "system_prompt": "You are…",
  "temperature": 0.7,
  "max_tokens": 2048,
  "memory": true,
  "widget_enabled": true,
  "allowed_domains": ["acme.com"],
  "delegation_enabled": true,
  "tools": {"builtin": ["web_search"], "custom": ["QXT-…"], "mcp_servers": ["QXM-…"]},
  "sub_agents": ["QXA-…"],
  "guardrails": ["QXG-…"]
}

The capability lists are read from the agent's saved workflow, not from a settings table — so what you see here is what will actually run, including a tool you switched off on the workflow canvas.

PATCH /v1/agents/{id}

Updates any agent. Send back the same shape GET returns; only the fields you include change.

agent = qx.agents.retrieve("QXA-8MQ7KN2A")
agent.update(
    system_prompt="Be concise and always cite a source.",
    tools={"builtin": ["web_search"], "custom": ["QXT-4K9WMZ2P"]},
    sub_agents=[billing.id],
    guardrails=["QXG-7DN3QX1B"],
    allowed_domains=["acme.com", "support.acme.com"],
)
curl -X PATCH https://qunivex.com/v1/agents/QXA-8MQ7KN2A \
  -H "Authorization: Bearer qvx-live-..." -H "Content-Type: application/json" \
  -d '{
    "system_prompt": "Be concise and always cite a source.",
    "tools": {"builtin": ["web_search"], "custom": ["QXT-4K9WMZ2P"]},
    "sub_agents": ["QXA-3P8LKD6Y"]
  }'
FieldNotes
name, description, system_promptdescription is sub-agents only.
model, temperature, max_tokens, top_pmodel is checked against your plan's catalog.
toolsAn object: builtin (names from /v1/tools), custom (QXT- ids), mcp_servers (QXM- ids). Each key is optional; omitting one leaves it alone.
sub_agentsQXA- ids this agent may delegate to. Setting a non-empty list turns delegation on.
guardrailsQXG- ids to apply.
allowed_domainsHostnames the chat widget may load on. Normalised for you, so https://Acme.com/ stores as acme.com (and covers its subdomains).
memory, routing, widget_enabled, delegation_enabled, is_activeBooleans. The main agent cannot be deactivated.

Ids that do not belong to this project are ignored rather than rejected, so a stale id in a list cannot fail an otherwise good update. Enabled tools are capped per agent by your plan; going over returns 403 and changes nothing.

DELETE /v1/agents/{id}

Deletes a sub-agent along with its own knowledge base and documents. The main agent cannot be deleted — delete the project instead.

9Knowledge 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 at chat time.

Sub-agents have their own. Every endpoint in this section takes ?agent=QXA-… to work on that sub-agent's knowledge base instead of the project's. Leave it off for the project's — the one the main agent and your website widget answer from.

GET /v1/projects/{id}/documents

Lists the uploaded documents with their chunk and token counts. Add ?include=website_info to get the AI-generated site summary in the same list, as an entry with "kind": "website_info".

for doc in project.documents.list():
    print(doc.filename, doc.status, doc.chunks, doc.tokens)

# Include the AI-fetched site summary alongside your files
for doc in project.documents.list(include_website_info=True):
    print(doc.kind, doc.filename, doc.tokens)

# A sub-agent's own knowledge base
for doc in billing.documents:
    print(doc.filename)
curl https://qunivex.com/v1/projects/QXP-1DM2J8K0/documents \
  -H "Authorization: Bearer qvx-live-..."

# With the AI-fetched site summary
curl "https://qunivex.com/v1/projects/QXP-1DM2J8K0/documents?include=website_info" \
  -H "Authorization: Bearer qvx-live-..."

# A sub-agent's knowledge base
curl "https://qunivex.com/v1/projects/QXP-1DM2J8K0/documents?agent=QXA-3P8LKD6Y" \
  -H "Authorization: Bearer qvx-live-..."

It is opt-in because the summary is not a file — it has no upload, no size and is regenerated wholesale — and because adding it silently would change the shape of a list existing code already walks.

GET /v1/projects/{id}/documents/{doc_id}

One document. Use website_info as the id to read the generated summary including its markdown — it has no file to download, so this is where the text lives.

doc = project.documents.retrieve(482)
print(doc.status, doc.chunks)
curl https://qunivex.com/v1/projects/QXP-1DM2J8K0/documents/482 \
  -H "Authorization: Bearer qvx-live-..."

POST /v1/projects/{id}/documents

Adds a document. Two request shapes are accepted — a multipart file upload, or JSON with the text inline:

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

# Into a sub-agent's own knowledge base
billing.documents.upload("invoicing-policy.pdf")
# A file
curl -X POST https://qunivex.com/v1/projects/QXP-1DM2J8K0/documents \
  -H "Authorization: Bearer qvx-live-..." \
  -F "file=@handbook.pdf"

# Or text you already have
curl -X POST https://qunivex.com/v1/projects/QXP-1DM2J8K0/documents \
  -H "Authorization: Bearer qvx-live-..." \
  -H "Content-Type: application/json" \
  -d '{"filename": "faq.txt", "text": "Q: Do you ship internationally? A: Yes…"}'

Supported file types: PDF, DOCX, TXT, MD, HTML, JSON, XML, RTF, YAML and other plain-text formats. Indexing happens inline, so the response already carries the final status and chunk count — there is nothing to poll.

Large files

For a file whose extraction might outlive your HTTP timeout, add ?wait=false. You get 202 with "status": "processing" straight away; poll the document until it is no longer processing.

doc = project.documents.upload("500-page-manual.pdf", wait=False)
while doc.processing:
    time.sleep(2)
    doc = project.documents.retrieve(doc.id)
print(doc.status, doc.chunks)
curl -X POST "https://qunivex.com/v1/projects/QXP-1DM2J8K0/documents?wait=false" \
  -H "Authorization: Bearer qvx-live-..." -F "file=@500-page-manual.pdf"

Uploads count against your plan's knowledge-base token budget and are refused with 403 once it is full.

DELETE /v1/projects/{id}/documents/{doc_id}

Removes a document and every embedding derived from it. website_info as the id clears the generated summary.

project.documents.delete(doc_id)
curl -X DELETE https://qunivex.com/v1/projects/QXP-1DM2J8K0/documents/482 \
  -H "Authorization: Bearer qvx-live-..."

Website Info — the AI-fetched docs

Point Qunivex at your site and it reads the pages you choose, writing a factual knowledge-base section from each one. For most agents this ends up being the largest single thing they know. It lives in the same store as your uploads and is retrieved the same way, but it is not a file, so it has its own endpoints.

GET /v1/projects/{id}/website-info

The current summary, markdown included.

{
  "object": "document", "id": "website_info", "kind": "website_info",
  "status": "ready",
  "content": "# Acme\n\nAcme sells widgets…",
  "tokens": 4180,
  "source_url": "https://acme.com",
  "routes": ["/", "/pricing", "/faq"],
  "created_at": "2026-08-04T09:12:00Z"
}

status is none, generating, ready or error.

PUT /v1/projects/{id}/website-info

Replaces the summary with your own markdown and re-embeds it. Generation is one way to fill this in, not the only one — if you already have good copy about your business, put it here rather than paying for a crawl that rewrites what you wrote.

project.website_info.set("# Acme\n\nAcme sells widgets. Refunds within 30 days.")
print(project.website_info.content)
curl -X PUT https://qunivex.com/v1/projects/QXP-1DM2J8K0/website-info \
  -H "Authorization: Bearer qvx-live-..." -H "Content-Type: application/json" \
  -d '{"content": "# Acme\n\nAcme sells widgets."}'

POST /v1/projects/{id}/website-info/generate

Reads the pages and writes the summary. Returns 202 immediately — one model call runs per page, which takes tens of seconds — so poll GET …/website-info until status leaves generating.

project.website_info.generate(
    website_url="https://acme.com",
    routes=["/", "/pricing", "/faq"],
)

# …or block until it is done
info = project.website_info.generate(routes=["/", "/pricing"], wait=True)
print(info.status, info.tokens)
curl -X POST https://qunivex.com/v1/projects/QXP-1DM2J8K0/website-info/generate \
  -H "Authorization: Bearer qvx-live-..." -H "Content-Type: application/json" \
  -d '{"website_url": "https://acme.com", "routes": ["/", "/pricing", "/faq"]}'

# then poll
curl https://qunivex.com/v1/projects/QXP-1DM2J8K0/website-info \
  -H "Authorization: Bearer qvx-live-..."

Both fields default to what is already stored on the project. At most 5 routes are read per run; extra ones are ignored rather than rejected. Generating for a sub-agent (?agent=) fills that sub-agent's own knowledge base.

10Tools, guardrails & MCP

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

Built-in tools

GET /v1/tools

The tools we ship — the valid values for an agent's tools.builtin. Free, and exempt from your monthly quota.

for t in qx.tools:
    print(t.name, "—", t.description)
curl https://qunivex.com/v1/tools -H "Authorization: Bearer qvx-live-..."

Custom tools

Tools you write, run on our side when the agent calls them. Distinct from your own functions, which run in your code, one turn at a time: a custom tool is part of the agent everywhere it is deployed — including on your website widget, where there is no code of yours to call.

GET /v1/projects/{id}/tools

POST /v1/projects/{id}/tools

# An HTTP tool — calls an endpoint, {{param}} is filled from the arguments
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,
                        "description": "Product SKU."}],
    },
)

# A Python tool — runs in an isolated sandbox
project.tools.create(
    "order-total", type="python",
    description="Adds tax and shipping to a subtotal.",
    config={
        "function_name": "run",
        "code": "def run(subtotal):\n    return {'total': subtotal * 1.18 + 4.99}\n",
        "parameters": [{"name": "subtotal", "type": "number", "required": True}],
    },
)

project.main_agent.update(tools={"custom": [stock.id]})    # now it is live
curl -X POST https://qunivex.com/v1/projects/QXP-1DM2J8K0/tools \
  -H "Authorization: Bearer qvx-live-..." -H "Content-Type: application/json" \
  -d '{"name": "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}]}}'

GET, PATCH and DELETE on /v1/tools/{id} read, edit and remove one. Deleting also unwires it from every agent that had it enabled, so no agent is left pointing at a tool that is gone. A python tool needs config.code and an http tool needs config.url; both are checked on write, rather than failing silently mid-conversation later.

Guardrails

Rules that block or warn on a message, applied before or after the model runs.

GET /v1/projects/{id}/guardrails

POST /v1/projects/{id}/guardrails

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"},
)
project.main_agent.update(guardrails=[rail.id])
curl -X POST https://qunivex.com/v1/projects/QXP-1DM2J8K0/guardrails \
  -H "Authorization: Bearer qvx-live-..." -H "Content-Type: application/json" \
  -d '{"name": "No card numbers", "guardrail_type": "condition",
       "subtype": "regex", "position": "input", "action": "block",
       "message": "Please do not share card details here.",
       "config": {"subtype": "regex", "pattern": "\\b(?:\\d[ -]*?){13,16}\\b"}}'
FieldNotes
guardrail_typecondition (declarative matching, the default) or python (your own function, sandboxed).
subtypeFor condition: keyword, regex, length_min, length_max, contains_url.
config{"subtype": …, "pattern": …}, or {"code": …, "function_name": …} for a Python rail.
positioninput, output or both.
actionblock or warn.
messageWhat the user sees when it fires. Blank uses our default wording.

A blocked message comes back as a normal completion whose finish_reason is content_filter and whose content is this message — not an error status. GET, PATCH and DELETE on /v1/guardrails/{id} manage one.

MCP servers

Remote Model Context Protocol servers whose tools your agents can call.

GET /v1/projects/{id}/mcp-servers

POST /v1/projects/{id}/mcp-servers

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={"mcp_servers": [server.id]})
curl -X POST https://qunivex.com/v1/projects/QXP-1DM2J8K0/mcp-servers \
  -H "Authorization: Bearer qvx-live-..." -H "Content-Type: application/json" \
  -d '{"name": "Internal Docs", "url": "https://mcp.acme.com/v1",
       "transport": "http", "auth_type": "bearer", "auth_token": "…"}'

Remote servers only — Streamable HTTP ("http") or the legacy HTTP+SSE ("sse"). Tool discovery runs on create and on any edit that could change the connection; check discovery_ok on the response, since a server that failed to connect is still created (so a wrong token is a PATCH away) but contributes no tools.

POST /v1/mcp-servers/{id}/refresh re-discovers a server's tools — specs are cached so chat opens no connection to build them, which means a server that adds a tool stays invisible until something re-discovers it.

Credentials are write-only. auth_token and custom headers are accepted but never returned by any endpoint. has_auth tells you whether one is stored. Omitting auth_token on a PATCH keeps the existing one, so renaming a server never requires re-sending a secret you may not have kept.

11Activity logs

GET /v1/projects/{id}/logs

What your agents have actually done — every chat turn, tool call and error, newest first, with token counts and timings. This is the same record the Logs page in your dashboard reads, so you can build your own reporting on it.

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

# Everything from one conversation, oldest first
for row in project.logs.conversation("conv-7f21"):
    print(row.type, row.summary)
curl "https://qunivex.com/v1/projects/QXP-1DM2J8K0/logs?source=public&type=chat&limit=20" \
  -H "Authorization: Bearer qvx-live-..."
{
  "object": "log", "id": 91422, "type": "chat", "source": "public",
  "status": "success",
  "agent": "QXA-8MQ7KN2A", "agent_name": "Acme Assistant",
  "conversation_id": "conv-7f21",
  "summary": "Do you ship to Ireland?",
  "tokens_input": 1840, "tokens_output": 96, "duration_ms": 2310,
  "created_at": "2026-08-04T09:12:00Z"
}
FilterNotes
sourcepublic (your deployed widget), test (the dashboard playground) or api (calls like this one). Comma-separated for several. Defaults to all three.
typechat, tool_call or error.
statussuccess or error.
agentA QXA- id, to scope to one agent.
conversation_idEverything from one exchange.
sinceAn ISO-8601 timestamp.

summary is the user's message, truncated. tokens_input counts the whole prompt the model saw — system prompt, tool schemas, retrieved knowledge and history — not just the message, so it is usually much larger than tokens_output.

12Models & usage

GET /v1/models

Lists the agents this key can run, in OpenAI's model-list schema, so an SDK's model picker populates itself. Each entry also carries a qunivex object with the agent's display name, its project, and the underlying model.

for m in qx.models():
    print(m.id, m.name, m.project_name, m.model_name)
for m in client.models.list():
    print(m.id, m.owned_by)
curl https://qunivex.com/v1/models -H "Authorization: Bearer qvx-live-..."

This is one of the two endpoints the stock OpenAI client can call directly — the other is chat completions.

GET /v1/usage

Your account's current-period usage and the key's own lifetime call count.

u = qx.usage()
print(f"{u.api_calls_used}/{u.api_calls_limit} calls, {u.api_calls_remaining} left")
curl https://qunivex.com/v1/usage -H "Authorization: Bearer qvx-live-..."

Neither of these two endpoints counts against your monthly quota — polling your own usage should not consume the thing you are polling.

13Python SDK

The qunivex package wraps everything above with typed results and no external dependencies beyond requests.

pip install qunivex
Reads are properties, not calls. qx.models, qx.usage, qx.tools, project.documents, reply.blocked — no parentheses. Parentheses in this SDK mean the call does work you might want to control, like .list(limit=…) or .upload(path).
from qunivex import Qunivex

qx = Qunivex(api_key="qvx-live-...")       # or set QUNIVEX_API_KEY

# ── Chat ──
reply = qx.chat("QXA-8MQ7KN2A", "What plans do you offer?")
print(reply.content)

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

# Multi-turn: a Conversation keeps the history for you and replays it.
convo = qx.conversation("QXA-8MQ7KN2A")
convo.send("What plans do you offer?")
convo.send("Which suits two people?")

# …and runs your own functions when the model asks for them.
convo = qx.conversation("QXA-8MQ7KN2A", tools=[GET_ORDER],
                        handlers={"get_order": my_lookup})
print(convo.send("Where is order A-4471?").content)

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

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

project = qx.projects.create("Acme Support", website_url="https://acme.com")
project.update(main_agent={"temperature": 0.3})

# ── Agents ──
billing = project.agents.create("Billing", description="Invoices and refunds.")
project.main_agent.update(
    tools={"builtin": ["web_search"]},
    sub_agents=[billing.id],
)

# ── Knowledge base ──
project.documents.upload("handbook.pdf")
project.documents.add_text("FAQ", "Q: Do you ship internationally? A: Yes…")
for doc in project.documents.list(include_website_info=True):
    print(doc.kind, doc.filename, doc.chunks)

project.website_info.generate(routes=["/", "/pricing"], wait=True)
print(project.website_info.content)

billing.documents.upload("invoicing-policy.pdf")     # the sub-agent's own KB

# ── Tools, guardrails, MCP ──
project.tools.create("check-stock", type="http",
                     config={"url": "https://api.acme.com/stock/{{sku}}"})
project.guardrails.create("No card numbers", subtype="regex",
                          config={"subtype": "regex", "pattern": r"\d{16}"})
project.mcp_servers.create("Internal Docs", "https://mcp.acme.com/v1")

# ── Logs & usage ──
for row in project.logs.list(source="public", limit=20):
    print(row.created_at, row.total_tokens, row.summary)

print(qx.usage.api_calls_remaining, qx.rate_limit)

The client raises typed exceptions — AuthenticationError, PermissionDeniedError, NotFoundError, RateLimitError, QuotaExceededError — all subclasses of QunivexError. It retries rate limits and transient server errors automatically with exponential backoff, honouring Retry-After; a quota 429 is deliberately not retried, since waiting will not clear it.

14Limits & errors

Rate & quota

Two separate limits apply, on different clocks. A burst limit of 120 requests per minute per key stops a runaway loop, and your plan's monthly quota bounds the total. Both return 429; the burst one carries a Retry-After header.

Every authenticated response tells you where you stand, so you can pace yourself instead of finding out by being refused:

HeaderMeans
X-RateLimit-LimitBurst ceiling — requests per minute for this key.
X-RateLimit-RemainingRequests left in the current burst window.
X-RateLimit-ResetSeconds until that window clears.
X-RateLimit-Limit-RequestsYour plan's monthly call allowance.
X-RateLimit-Remaining-RequestsCalls left this month. Waiting does not raise this one — only a new billing period or a bigger plan does.

The SDK keeps the last set on qx.rate_limit.

/v1/models, /v1/tools and /v1/usage are exempt from the monthly quota.

Error shape

Errors use OpenAI's envelope, so SDK exception handling works as written:

{"error": {"message": "Invalid API key.",
           "type": "authentication_error",
           "code": "invalid_api_key"}}
StatusMeans
400Malformed request — a missing field, a bad role, an unsupported file type.
401Missing, unknown or revoked key.
403Your plan does not include the API, your account is on hold, or the knowledge-base budget is full.
404No such project, agent, tool, guardrail, server or document — or one this key is not scoped to reach.
409The resource exists but is in the wrong state — a disabled agent, or trying to delete the main agent.
429Burst limit or monthly quota reached.
503A dependency (the embedding provider, for search) is temporarily unavailable.

Getting help

Something not behaving as documented? Get in touch on the Contact page.