Build a RAG agent without the gateway
What you'll build: a Python script that indexes the AcruxCore docs, then answers a question about them two ways — retrieve-then-ask, and let-the-model-search — with every model call going straight to OpenRouter and every run showing up as a trace.
RAG means retrieval-augmented generation: before you ask the model a question, you look up the passages most likely to answer it and paste them into the prompt.
The gateway path stores your provider key server-side and routes every call through AcruxCore — useful for budget controls, caching, and fallback routing. BYO skips that: you pass the SDK an OpenAI-compatible base_url and api_key, and it calls the endpoint directly. Your key never reaches our servers, you save a network hop, and you still get versioned prompts, a tool catalog, and full traces.
acrux_rag.ipynb
is this whole page as one runnable notebook, written for a first-timer: a preflight cell that
checks both keys — including whether your provider really serves embeddings — the index built
step by step, a live read of the traces both answering styles produce, and four real ways to get
BYO wrong. It renders on GitHub with its saved output, so you can read it through before running
anything.
1. Store the two prompts
Both answering styles need a prompt, and both belong in AcruxCore rather than hardcoded in the script — that way you can edit the wording and ship it without a redeploy. Create rag-chat for the linear path, with a system message that takes the retrieved passages as a context variable and a user message that takes the question.

Leave Default model empty. That field binds a model from the gateway's registry, and this guide never touches the gateway — the model id belongs to OpenRouter, so the script passes it directly.
Then create rag-chat-agent the same way. Its system message tells the model to look things up with a tool instead of handing it context up front:
You answer questions about AcruxCore. Use the search_docs tool to look
things up before answering — search more than once if the first result is
thin, or if the question has several parts. Answer only from what the tool
returns, and say so plainly when it comes back without the answer.
2. Point the SDK at OpenRouter
This is the whole BYO feature — one dictionary, passed to every model call:
import acruxcore as acrux
PROVIDER: acrux.ProviderConfig = {
"base_url": "https://openrouter.ai/api/v1",
"api_key": os.environ["OPENROUTER_API_KEY"],
}
CHAT_MODEL = "google/gemini-3.7-flash"
EMBED_MODEL = "openai/text-embedding-3-small"
Any OpenAI-compatible endpoint works here — Groq, Together, vLLM, or Ollama on your laptop. The api_key is sent as a bearer token to base_url and nowhere else.
3. Index the docs
Fetch each guide, split it into overlapping chunks, and embed them. OpenRouter serves embeddings from the same host and the same key as the chat model, so the script needs only one provider credential:
def embed_texts(texts: list[str]) -> list[list[float]]:
resp = requests.post(
f"{PROVIDER['base_url']}/embeddings",
headers={"Authorization": f"Bearer {PROVIDER['api_key']}"},
json={"model": EMBED_MODEL, "input": texts},
timeout=60,
)
resp.raise_for_status()
data = resp.json()["data"]
# OpenRouter does not promise input order, so sort by the echoed index.
return [row["embedding"] for row in sorted(data, key=lambda r: r["index"])]
Store the vectors in Chroma, an embedded vector database that needs no server:
collection = chromadb.Client().get_or_create_collection(
name="acrux_docs", metadata={"hnsw:space": "cosine"}
)
collection.add(ids=ids, embeddings=embeddings, documents=documents, metadatas=metadatas)
Running this over the five guides gives 31 chunks:
fetched prompts.md: 4,805 chars → 6 chunks
fetched gateway.md: 4,173 chars → 5 chunks
fetched tracing.md: 3,903 chars → 5 chunks
fetched tools.md: 7,441 chars → 9 chunks
fetched evaluation.md: 4,421 chars → 6 chunks
indexed 31 chunks from 5 documents
4. Answer it linearly
Retrieve first, then make exactly one model call. Report the retrieval as its own span and hand its trace id to hub.gateway.chat(), so the passages and the answer land in the same trace:
reported = await hub.traces.ingest({
"name": "rag-linear",
"spans": [{
"spanId": "retrieval", "name": "search_docs", "kind": "retrieval",
"status": "ok", "startTime": started, "endTime": _now(),
"input": {"query": question}, "output": {"context": context},
}],
})
rendered = await hub.prompts.render("rag-chat", "production",
{"context": context, "question": question})
result = await hub.gateway.chat(
rendered.model or CHAT_MODEL,
rendered.messages,
provider=PROVIDER,
prompt_version_id=rendered.version_id,
trace={"trace_id": reported.trace_id},
)
Open Traces and the run is there — the retrieval that found the passages, then the model call that used them:

Expand the LLM span and the BYO call is fully accounted for — tokens, latency, and openrouter.ai as the provider:

Two things worth noticing. There is no Linked gateway request row, because no gateway was involved. And View traces for this prompt version works anyway: passing prompt_version_id from hub.prompts.render() links the trace back to the exact prompt version that produced it, so you can still ask "how did v3 do?" after you ship v4.
Tokens, latency, model, and payloads are all recorded, but costUsd is empty — we only price calls that went through the gateway, where we know the rate card. Your provider's own dashboard has the bill.
5. Answer it agentically, with a tool
The linear version always retrieves, once, whether or not the question needs it. The agentic version hands the retriever to the model as a tool and lets it decide. Decorate the function and pass it to run_tool_loop:
@acrux.tool
async def search_docs(query: str) -> str:
"""Search AcruxCore's documentation for relevant information.
Args:
query: The search query — a question or topic to look up.
"""
return retrieve_context(_COLLECTION, query)
rendered = await hub.prompts.render("rag-chat-agent", "production", {"question": question})
result = await hub.gateway.run_tool_loop(
rendered.model or CHAT_MODEL,
rendered.messages,
tools=[search_docs],
provider=PROVIDER,
prompt_version_id=rendered.version_id,
)
The loop calls the model, runs whatever tools it asks for, feeds the results back, and repeats until the model stops asking. The trace shows that rhythm directly — model, tool, model:

The tool span nests under the model call that requested it, so a loop that searched four times is four nested spans, not a flat list you have to reconstruct.
On the BYO path there is no gateway to look tool schemas up server-side, so the SDK inlines the full JSON Schema into the request for you. It also syncs the tool to your catalog on the first call, deriving the name, description, and parameters from the function itself:

Tools are versioned like prompts. Here the docstring in the script became v2, and the earlier version someone wrote in the dashboard is still there to promote back to.
Doing this over the API
You don't need the SDK. The BYO path is three independent HTTP calls: render the prompt from us, call OpenRouter yourself, report the trace back to us. This is the route to take from a language with no AcruxCore SDK.
- curl
- Python (SDK)
- Node (SDK)
- Python (requests)
Render the prompt. Note versionId — that's what links a trace to the version that produced it:
curl -X POST "$ACRUXCORE_BASE_URL/prompts/rag-chat/production/render" \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"variables":{"context":"[tools.md]\nOpen the prompt and go to the Tools tab.","question":"How do I attach a tool?"}}'
{
"messages": [
{
"role": "system",
"content": "You answer questions about AcruxCore using only the documentation excerpts below. If the excerpts do not contain the answer, say so plainly instead of guessing.\n\nDocumentation:\n[tools.md]\nOpen the prompt and go to the Tools tab."
},
{ "role": "user", "content": "How do I attach a tool?" }
],
"tools": [],
"model": null,
"versionId": "72bed36e-6d77-425e-878c-ec534ca43b8e",
"versionNumber": 1
}
Call OpenRouter directly — this request never touches AcruxCore:
curl -X POST "https://openrouter.ai/api/v1/chat/completions" \
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"google/gemini-3.7-flash","messages":[...],"max_tokens":80}'
{
"id": "gen-1785427905-CA5ECKBA1Pg2RJAE7mfX",
"model": "google/gemini-3.7-flash",
"provider": "OpenAI",
"choices": [
{
"index": 0,
"finish_reason": "stop",
"message": {
"role": "assistant",
"content": "[tools.md] Open the prompt and go to the Tools tab, then use Connect a tool from the catalog to pick the tool."
}
}
],
"usage": { "prompt_tokens": 51, "completion_tokens": 29, "total_tokens": 80 }
}
Report the trace. Spans are a batch under traces, and keys are camelCase because they go to the API verbatim:
curl -X POST "$ACRUXCORE_BASE_URL/traces" \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"traces": [{
"name": "rag-linear",
"spans": [
{"spanId":"s1","name":"search_docs","kind":"retrieval","status":"ok",
"startTime":"2026-07-30T16:11:44.000Z","endTime":"2026-07-30T16:11:44.000Z",
"input":{"query":"How do I attach a tool?"}},
{"spanId":"s2","name":"google/gemini-3.7-flash","kind":"llm","status":"ok",
"startTime":"2026-07-30T16:11:44.000Z","endTime":"2026-07-30T16:11:44.000Z",
"model":"google/gemini-3.7-flash","provider":"openrouter.ai",
"promptVersionId":"72bed36e-6d77-425e-878c-ec534ca43b8e",
"usage":{"promptTokens":51,"completionTokens":29,"totalTokens":80}}
]
}]
}'
{ "accepted": 2, "traceIds": ["a8b9b021-ade7-4712-98d6-c9f9417f7e67"] }
The SDK wraps all three calls. hub.prompts.render() returns the messages, tools, model, and versionId in one call. Pass the provider option to hub.gateway.chat() to call OpenRouter directly — the SDK mints a trace id, makes the call, and reports the llm span for you. You only need to report the retrieval span yourself (the SDK has no opinion on what you did before the model call):
import acruxcore as acrux
PROVIDER: acrux.ProviderConfig = {
"base_url": "https://openrouter.ai/api/v1",
"api_key": os.environ["OPENROUTER_API_KEY"],
}
async with AcruxCore() as hub:
# Report the retrieval span
reported = await hub.traces.ingest({
"name": "rag-linear",
"spans": [{
"spanId": "retrieval", "name": "search_docs", "kind": "retrieval",
"status": "ok", "startTime": started, "endTime": _now(),
"input": {"query": question}, "output": {"context": context},
}],
})
# Render the prompt (returns messages, tools, model, versionId)
rendered = await hub.prompts.render("rag-chat", "production",
{"context": context, "question": question})
# Call OpenRouter directly — SDK reports the llm span automatically
result = await hub.gateway.chat(
rendered.model or "google/gemini-3.7-flash",
rendered.messages,
provider=PROVIDER,
prompt_version_id=rendered.version_id,
trace={"trace_id": reported.trace_id},
)
{
"messages": [
{ "role": "system", "content": "You answer questions about AcruxCore using only the documentation excerpts below..." },
{ "role": "user", "content": "How do I attach a tool?" }
],
"tools": [],
"model": null,
"versionId": "72bed36e-6d77-425e-878c-ec534ca43b8e",
"versionNumber": 1
}
The SDK handles the trace id threading, the llm span report, and the prompt version linkage — three things the curl tab does by hand.
Same shape as the Python SDK. hub.prompts.render() returns messages, tools, model, and versionId. Pass the provider option to hub.gateway.chat() to call OpenRouter directly — the SDK mints a trace id, makes the call, and reports the llm span for you. You only need to report the retrieval span yourself:
import { acruxcore } from '@acruxcoreai/sdk';
const PROVIDER = {
baseUrl: "https://openrouter.ai/api/v1",
apiKey: process.env.OPENROUTER_API_KEY!,
};
const hub = new acruxcore();
// Report the retrieval span
const reported = await hub.traces.ingest({
name: "rag-linear",
spans: [{
spanId: "retrieval", name: "search_docs", kind: "retrieval",
status: "ok", startTime: started, endTime: _now(),
input: { query: question }, output: { context },
}],
});
// Render the prompt (returns messages, tools, model, versionId)
const rendered = await hub.prompts.render("rag-chat", "production",
{ context, question });
// Call OpenRouter directly — SDK reports the llm span automatically
const result = await hub.gateway.chat(
rendered.model ?? "google/gemini-3.7-flash",
rendered.messages,
{ provider: PROVIDER, promptVersionId: rendered.versionId,
trace: { traceId: reported.traceId } },
);
{
"messages": [
{ "role": "system", "content": "You answer questions about AcruxCore using only the documentation excerpts below..." },
{ "role": "user", "content": "How do I attach a tool?" }
],
"tools": [],
"model": null,
"versionId": "72bed36e-6d77-425e-878c-ec534ca43b8e",
"versionNumber": 1
}
The SDK handles the trace id threading, the llm span report, and the prompt version linkage — three things the curl tab does by hand.
Embeddings come from the same host and key as the chat model:
resp = requests.post(
"https://openrouter.ai/api/v1/embeddings",
headers={"Authorization": f"Bearer {os.environ['OPENROUTER_API_KEY']}"},
json={"model": "openai/text-embedding-3-small",
"input": "Connect a tool to a prompt"},
timeout=60,
)
print(resp.json())
{
"object": "list",
"data": [
{ "object": "embedding", "index": 0,
"embedding": [-0.009002685546875, 0.040771484375, 0.01045989990234375, "…"] }
],
"model": "text-embedding-3-small",
"usage": { "prompt_tokens": 7, "total_tokens": 7 },
"provider": "OpenAI"
}
The prompt render and trace report are ordinary requests.post calls against the two AcruxCore endpoints shown in the curl tab.
The whole script
Run python acrux_rag.py --setup once to create the two prompts, then
python acrux_rag.py "your question" to answer with both styles. See
acrux_rag.py on GitHub for the complete source.
What's next
- Build a tool-calling agent in Python (SDK) — the same
run_tool_loop, on the gateway path, with several tools running concurrently. - Route your app's LLM calls through the gateway — the other side of this trade: one hop more, in exchange for budgets, caching, fallback routing, and dollar cost on every span.
- Full-cycle latency across six LLM-ops platforms — the measurements behind "you save a network hop": this BYO path came back statistically indistinguishable from calling OpenAI directly, in every one of four independent runs.
- Using sessions and traces — group several runs into one session and search across them.
- API details: see Prompts and Traces in the API Reference.