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.
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.
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:
| Prefix | What it names | Example |
|---|---|---|
QXP- | Project | QXP-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
| Field | Type | Notes |
|---|---|---|
model | string | Required. An agent id (QXA-…) or a project id (QXP-…). |
messages | array | Required. Roles system, user, assistant. The last message must be a user one. |
stream | boolean | Server-sent events, terminated by data: [DONE]. Default false. |
temperature | number | 0–2. Defaults to the agent's own setting. |
max_tokens | integer | Defaults to the agent's own setting. |
conversation_id | string | Optional. 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
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.
| Field | Type | Notes |
|---|---|---|
project | string | Required. A QXP- project id. |
query | string | Required. Natural language; it is embedded, not keyword-matched. |
top_k | integer | 1–20, default 5. |
agent | string | Optional. 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."}'
| Field | Notes |
|---|---|
name | Required. |
description | Your own note; not shown to the model. |
model | Defaults to your plan's default model. |
agent_name, system_prompt, temperature, max_tokens | Applied to the main agent. |
website_url | Also whitelisted for the chat widget, so the embed works straight away. |
website_name, website_description, target_audience, tone_style | Business context the agent is given about you. |
routes | Pages to read later with Website Info. |
widget_enabled | Default 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-..."
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"]
}'
| Field | Notes |
|---|---|
name, description, system_prompt | description is sub-agents only. |
model, temperature, max_tokens, top_p | model is checked against your plan's catalog. |
tools | An object: builtin (names from /v1/tools), custom (QXT- ids), mcp_servers (QXM- ids). Each key is optional; omitting one leaves it alone. |
sub_agents | QXA- ids this agent may delegate to. Setting a non-empty list turns delegation on. |
guardrails | QXG- ids to apply. |
allowed_domains | Hostnames 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_active | Booleans. 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.
?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"}}'
| Field | Notes |
|---|---|
guardrail_type | condition (declarative matching, the default) or python (your own function, sandboxed). |
subtype | For condition: keyword, regex, length_min, length_max, contains_url. |
config | {"subtype": …, "pattern": …}, or {"code": …, "function_name": …} for a Python rail. |
position | input, output or both. |
action | block or warn. |
message | What 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.
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"
}
| Filter | Notes |
|---|---|
source | public (your deployed widget), test (the dashboard playground) or api (calls like this one). Comma-separated for several. Defaults to all three. |
type | chat, tool_call or error. |
status | success or error. |
agent | A QXA- id, to scope to one agent. |
conversation_id | Everything from one exchange. |
since | An 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
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:
| Header | Means |
|---|---|
X-RateLimit-Limit | Burst ceiling — requests per minute for this key. |
X-RateLimit-Remaining | Requests left in the current burst window. |
X-RateLimit-Reset | Seconds until that window clears. |
X-RateLimit-Limit-Requests | Your plan's monthly call allowance. |
X-RateLimit-Remaining-Requests | Calls 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"}}
| Status | Means |
|---|---|
400 | Malformed request — a missing field, a bad role, an unsupported file type. |
401 | Missing, unknown or revoked key. |
403 | Your plan does not include the API, your account is on hold, or the knowledge-base budget is full. |
404 | No such project, agent, tool, guardrail, server or document — or one this key is not scoped to reach. |
409 | The resource exists but is in the wrong state — a disabled agent, or trying to delete the main agent. |
429 | Burst limit or monthly quota reached. |
503 | A dependency (the embedding provider, for search) is temporarily unavailable. |
Getting help
Something not behaving as documented? Get in touch on the Contact page.