Tutorial: RAG chatbot over your own documents
By the end of this tutorial you will have a deployed agent that answers questions about your documents. When the documents do not cover a question, it says so instead of making something up. And it names the file every answer came from.
The technique is called RAG: retrieval-augmented generation. Before the model answers, you retrieve the handful of passages most likely to be relevant and hand them to it. The model's job shrinks from "know everything" to "read these three paragraphs and answer the question", which is a job models are good at.
Budget about 40 minutes. Every command is copy-paste-runnable.
What you are building
Two terms, defined once:
- An embedding is a list of numbers that captures the meaning of a piece of text. Two texts that mean similar things get numerically similar embeddings, even when they share no words.
- A vector index stores those number lists and finds the nearest ones fast. That is what VectorDB is.
The important honesty up front: VectorDB stores and searches vectors, it does not create them. You bring your own embeddings, so you choose the embedding model. One rule follows from that, and it is the rule people break: use the same model when loading documents and when asking questions. Use two different models and the numbers will not line up, and your search results will be nonsense.
Before you begin
You need:
- A platform account and a project, with the admin role on that project (creating a service account is an admin action). Ask your administrator for an account or an invitation link — see Create an account.
platformctl, signed in — see Install the CLI.curl,jq, andpython3(standard library only — nothing topip install).- Your project connected to Crusoe Cloud. Everything you deploy is built into a container image, and that image is stored in a repository in your own Crusoe Cloud Registry — so a project with no Crusoe Cloud credential is refused before anything is built. Connecting is a one-time, project-admin step, and a project that is already connected needs nothing new. See connect your Crusoe Cloud account.
- An embeddings endpoint. You do not have to bring one. The platform hosts
qwen-embeddingand serves it on the same public API as everything else on this page —POST $CAI_API/v1/embeddings, authenticated with your own$CAI_TOKEN.platformctl inference modelslists what your install serves. Any OpenAI-compatible provider works instead; the one rule is to use the same model for loading and for querying.
Both surfaces this tutorial calls — agents and VectorDB — live on the public API, https://api.codyhill.dev, and platformctl needs neither address set: it defaults to the same one. Only an install that serves VectorDB somewhere else needs a different $VDB (platformctl resolves $CAI_VECTORDB_API first, then $CAI_API, then the public default).
Set your shell up once:
export CAI_API="https://api.codyhill.dev" # the platform API
export VDB="$CAI_API" # VectorDB is served on it too
# $CAI_TOKEN is cached by 'platformctl login'
export CAI_PROJECT="0f7a5c21-...-uuid" # your project UUID - the ID column below
export INDEX="handbook"
If $CAI_TOKEN is empty (platformctl login caches its token in a file, not in your shell), get one directly — session tokens last 12 hours:
export CAI_TOKEN=$(curl -s "$CAI_API/v1/auth/login" \
-H 'Content-Type: application/json' \
-d '{"email":"you@example.com","password":"your-password"}' | jq -r .token)
A UUID is a long unique id, like 0f7a5c21-.... Your project's appears in the console URL when you open the project, in the #/projects/ path segment. Or list it:
platformctl projects list
You should see:
SLUG NAME SHORT ROLE ID
ml-team ML Team ab12cd admin 0f7a5c21-...-uuid
The ID column is what CAI_PROJECT holds. Last, the embedding model — now that $CAI_TOKEN is in your shell:
export EMBED_BASE_URL="$CAI_API/v1"
export EMBED_API_KEY="$CAI_TOKEN" # the platform's own endpoint takes your platform credential
export EMBED_MODEL="qwen-embedding" # 4096 numbers wide - Act 2 measures it rather than trusting this
Pointing these at another provider is a supported swap — change all three here, and set the matching HANDBOOK_EMBED_* on the agent in Act 7, so both halves keep using one model.
Act 1: create the documents
Real document sets are messy. Start with three tiny files instead, so you can check every answer by eye.
mkdir -p handbook
cat > handbook/pto.md <<'EOF'
Paid time off. Every employee gets 25 days of paid time off each year.
Unused days roll over into the next year, up to a maximum of 5 days.
Request time off at least two weeks in advance.
EOF
cat > handbook/expenses.md <<'EOF'
Expenses. Submit receipts within 30 days of the purchase.
Anything above 500 dollars needs written manager approval before you buy it.
Travel booked through the company portal is billed directly and needs no receipt.
EOF
cat > handbook/laptops.md <<'EOF'
Laptops. The standard issue is a 14-inch laptop, refreshed every 3 years.
Ask IT for an exception if your work needs more memory or a discrete GPU.
Report a lost or stolen laptop to IT within 24 hours.
EOF
Each file is one chunk — one unit that gets embedded and retrieved as a whole. With real documents you would split long files into overlapping chunks of a few hundred words. A chunk is also the unit of text the model reads. Make chunks too large and you drown the model in irrelevant text. Make them too small and you cut answers in half.
At this point you have: three plain-text documents on disk.
Act 2: find out how wide your vectors are
An index has a fixed vector width, called its dimensions — how many numbers are in each vector. It has to match your embedding model exactly, and it can never be changed after the index is created. So rather than look the number up, measure it.
Save this script — you will use it again in Act 4 to load the documents:
#!/usr/bin/env python3
"""Load a folder of text files into a Crusoe VectorDB index."""
import json
import os
import pathlib
import sys
import urllib.error
import urllib.request
EMBED_BASE_URL = os.environ["EMBED_BASE_URL"].rstrip("/")
EMBED_API_KEY = os.environ["EMBED_API_KEY"]
EMBED_MODEL = os.environ["EMBED_MODEL"]
VDB = os.environ.get("VDB", "").rstrip("/")
TOKEN = os.environ.get("CAI_TOKEN", "")
PROJECT = os.environ.get("CAI_PROJECT", "")
INDEX = os.environ.get("INDEX", "handbook")
def post(url, payload, token):
request = urllib.request.Request(
url,
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json",
"Authorization": "Bearer " + token},
)
try:
with urllib.request.urlopen(request, timeout=60) as response:
return json.load(response)
except urllib.error.HTTPError as error:
sys.exit("HTTP %s from %s: %s" % (error.code, url, error.read().decode()))
def embed(text):
body = post(EMBED_BASE_URL + "/embeddings",
{"model": EMBED_MODEL, "input": text},
EMBED_API_KEY)
return body["data"][0]["embedding"]
def main():
if len(sys.argv) > 1 and sys.argv[1] == "dims":
print(len(embed("hello")))
return
points = []
for number, path in enumerate(sorted(pathlib.Path("handbook").glob("*.md")), start=1):
text = path.read_text().strip()
points.append({
"id": number, # stable id: re-running updates, never duplicates
"vector": embed(text),
"payload": {"text": text, "source": path.name},
})
print("embedded %s (%d characters)" % (path.name, len(text)))
result = post("%s/v1/projects/%s/indexes/%s:upsert" % (VDB, PROJECT, INDEX),
{"points": points}, TOKEN)
print(json.dumps(result))
main()
Save it as ingest.py, then measure:
python3 ingest.py dims
You should see a single number, for example:
4096
Record it:
export DIMS=4096 # use whatever your model printed
At this point you have: the exact vector width your embedding model produces.
Act 3: create the index
Create an index named handbook, with that width and cosine distance. Cosine is the usual choice, because it compares the direction of two vectors — and direction is what "similar meaning" looks like numerically.
- platformctl
- curl
- Console
platformctl vectordb create "$INDEX" --dimensions "$DIMS" --distance cosine
The index is created in the background, so ask for it again until it reports ready:
platformctl vectordb get "$INDEX"
You should see state ready and ready true, with points_count 0.
curl -sX POST "$VDB/v1/projects/$CAI_PROJECT/indexes" \
-H "Authorization: Bearer $CAI_TOKEN" -H 'Content-Type: application/json' \
-d "{\"name\":\"$INDEX\",\"dimensions\":$DIMS,\"distance\":\"cosine\"}"
You should see (HTTP 201):
{"name":"handbook","resource_path":"projects/ab12cd/indexes/handbook","collection":"p_ab12cd_handbook","dimensions":4096,"distance":"cosine","state":"pending","ready":false,...}
The index is created in the background, so ask for it again until it reports ready:
curl -s "$VDB/v1/projects/$CAI_PROJECT/indexes/$INDEX" \
-H "Authorization: Bearer $CAI_TOKEN" | jq '{state, ready, points_count}'
You should see:
{
"state": "ready",
"ready": true,
"points_count": 0
}
Go to Data services → VectorDB and click Create index. Name it handbook, set dimensions to the number you measured in Act 2, and pick cosine for the distance.
You should see: the new index listed with a State panel that reports pending and then ready. Until it is ready the Data tab shows Not queryable yet.
If you write points before the index is ready, you get a clear 409: index is not ready yet; its collection has not been created.
Omit dimensions — from the console form, from --dimensions, or from the JSON body — and the index is created 1536 wide, the width of the most common OpenAI embedding models. The platform's own qwen-embedding produces 4096. Nothing catches the mismatch at create time: it surfaces at your first upsert, as point 0 has 4096 dimensions; index "handbook" expects 1536, and because an upsert is all-or-nothing the index stays empty. Dimensions cannot be edited afterwards, so the recovery is deleting the index and doing this act again. Always pass the number you measured in Act 2.
dimensions and distance cannot be changed later. Switching embedding models means creating a new index and loading everything again. The API says so out loud if you try: dimensions is immutable: existing points cannot be re-embedded, so a different width is a new index, not an edit.
At this point you have: an empty, ready index sized for your embedding model.
Act 4: load the documents
python3 ingest.py
You should see:
embedded expenses.md (198 characters)
embedded laptops.md (191 characters)
embedded pto.md (168 characters)
{"index": "handbook", "upserted_count": 3}
Each document became a point: a vector, plus a JSON payload carrying the original text and its file name. The payload is what makes citations possible later. The vector finds the passage. The payload hands you something a human can read.
Two things the script does on purpose:
- Stable ids. Point 1 is always
expenses.md. Re-run the script after editing a document and it updates that point instead of adding a duplicate. - One batch. A single upsert is all-or-nothing. If one vector is the wrong width, the whole batch is rejected, so you never end up half-loaded. You would see
point 2 has 1536 dimensions; index "handbook" expects 4096.
Check what landed, without needing a query vector:
- platformctl
- curl
- Console
platformctl vectordb scroll "$INDEX" --limit 50
You should see the three points listed in id order, each with its payload.
curl -sX POST "$VDB/v1/projects/$CAI_PROJECT/indexes/$INDEX:scroll" \
-H "Authorization: Bearer $CAI_TOKEN" -H 'Content-Type: application/json' \
-d '{"limit":50}' | jq '.points[] | {id, source: .payload.source}'
You should see:
{"id": 1, "source": "expenses.md"}
{"id": 2, "source": "laptops.md"}
{"id": 3, "source": "pto.md"}
Open the index and go to its Data tab. It lists the points in id order; click a row for the whole payload.
At this point you have: a searchable index containing your documents. You could stop here and build any search UI you like on top of it — see search.
Act 5: make a credential for the agent
Your agent will call the VectorDB API from inside the platform, so it needs its own credential. Do not hand it your personal one. Create a service account instead: a machine identity that belongs to this project and holds a project role the way a person does.
The member role is exactly enough: members can query an index, but cannot delete one. The same key also covers the embeddings call — the hosted models authorize per principal, not per project role, so any identity the platform recognizes may use them.
- platformctl
- curl
- Console
platformctl service-accounts create handbook-reader \
--display-name "RAG chatbot" --role member
Now mint a key:
platformctl service-accounts keys create handbook-reader \
--display-name handbook-bot --expires-in-days 90
You should see the key id and, once only, the secret itself.
curl -s -X POST "$CAI_API/v1/projects/$CAI_PROJECT/service-accounts" \
-H "Authorization: Bearer $CAI_TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"handbook-reader","display_name":"RAG chatbot","role":"member"}'
You should see:
{"service_account":{"name":"handbook-reader","email":"handbook-reader@ab12cd.cai.local","role":"member","disabled":false,...},"note":"create a key for it at POST .../handbook-reader/keys"}
Now mint a key:
curl -s -X POST "$CAI_API/v1/projects/$CAI_PROJECT/service-accounts/handbook-reader/keys" \
-H "Authorization: Bearer $CAI_TOKEN" -H 'Content-Type: application/json' \
-d '{"display_name":"handbook-bot","expires_in_days":90}'
You should see:
{"key":{"key_id":"...","display_name":"handbook-bot","live":true,...},"secret":"cai_xxxxxxxx_yyyyyyyyyyyy","note":"copy this now - only a hash is stored, so it cannot be shown again. If it is lost, revoke this key and create another."}
Go to Security → Service accounts and click Create service account. Name it handbook-reader, give it the display name RAG chatbot, and set the role to member.
Then open it, click Create key, and set an expiry. The secret is shown once, in that dialog.
The secret appears in that one response and nowhere else — only a hash is stored. If you lose it, revoke the key and create another.
export SA_KEY="cai_xxxxxxxx_yyyyyyyyyyyy" # paste yours
Not a project admin? Use a personal API key instead (POST /v1/users/me/keys, available to any signed-in user). Be aware that it acts as you: every call the agent makes carries your identity. A service account is the right shape for a workload. See service accounts and API keys.
At this point you have: a credential scoped to this project, which you can revoke without touching your own account.
Act 6: write the agent
The agent gets one tool: search_handbook. The model decides when to call it, the tool does the retrieval, and the instruction tells the model to answer only from what came back.
The retrieval logic is identical in all three frameworks — only the way the tool is declared, and the symbol the platform looks for, changes.
- ADK
- CrewAI
- LangGraph
mkdir -p rag-chatbot && cat > rag-chatbot/agent.py <<'PYEOF'
import json
import os
import urllib.request
from google.adk.agents import Agent
from crusoe_adk.foundry import foundry_model
def _post(url, payload, token):
request = urllib.request.Request(
url,
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json",
"Authorization": "Bearer " + token},
)
with urllib.request.urlopen(request, timeout=30) as response:
return json.load(response)
def _retrieve(question):
# Read configuration HERE, at call time - never at import. See below.
# Each platform address is injected; the HANDBOOK_* names only override it.
vectordb_api = (os.environ.get("VECTORDB_API")
or os.environ["CAI_VECTORDB_URL"]).rstrip("/")
embed_base = (os.environ.get("HANDBOOK_EMBED_BASE_URL")
or os.environ["EMBED_BASE_URL"]).rstrip("/")
embed_model = os.environ.get("HANDBOOK_EMBED_MODEL") or os.environ["EMBED_MODEL"]
index = os.environ.get("HANDBOOK_INDEX", "handbook")
top_k = int(os.environ.get("HANDBOOK_TOP_K", "3"))
embedded = _post(
embed_base + "/embeddings",
{"model": embed_model, "input": question},
os.environ["HANDBOOK_EMBED_API_KEY"],
)
vector = embedded["data"][0]["embedding"]
found = _post(
"%s/v1/projects/%s/indexes/%s:query"
% (vectordb_api, os.environ["CAI_PROJECT_ID"], index),
{"vector": vector, "top_k": top_k, "with_payload": True},
os.environ["VECTORDB_TOKEN"],
)
hits = found.get("results", [])
if not hits:
return "No matching handbook passage found."
return "\n\n".join(
"[%s] %s" % (hit.get("payload", {}).get("source", "handbook"),
hit.get("payload", {}).get("text", ""))
for hit in hits
)
def search_handbook(question: str) -> str:
"""Search the company handbook for passages that answer a question.
Args:
question: The question to look up, in plain English.
Returns:
The most relevant handbook passages, each labeled with its source file.
"""
return _retrieve(question)
root_agent = Agent(
name="handbook_bot",
model=foundry_model(),
instruction=(
"You answer questions about the company handbook. Always call "
"search_handbook first, and answer only from the passages it returns. "
"Name the source file you used. If the passages do not contain the "
"answer, say so plainly instead of guessing."
),
tools=[search_handbook],
)
PYEOF
The docstring is the interface. ADK builds the tool's schema from the function signature and the docstring, so when the model decides how to call this tool, it reads your words: question: The question to look up, in plain English. Vague docstrings produce vague tool calls.
mkdir -p rag-chatbot && cat > rag-chatbot/crew.py <<'PYEOF'
import json
import os
import urllib.request
from crewai import Agent, Crew, Process, Task
from crewai.tools import tool
import crusoe_crewai as crusoe
def _post(url, payload, token):
request = urllib.request.Request(
url,
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json",
"Authorization": "Bearer " + token},
)
with urllib.request.urlopen(request, timeout=30) as response:
return json.load(response)
@tool("search_handbook")
def search_handbook(question: str) -> str:
"""Search the company handbook for passages that answer a question."""
# Read configuration HERE, at call time - never at import. See below.
# Each platform address is injected; the HANDBOOK_* names only override it.
vectordb_api = (os.environ.get("VECTORDB_API")
or os.environ["CAI_VECTORDB_URL"]).rstrip("/")
embed_base = (os.environ.get("HANDBOOK_EMBED_BASE_URL")
or os.environ["EMBED_BASE_URL"]).rstrip("/")
embed_model = os.environ.get("HANDBOOK_EMBED_MODEL") or os.environ["EMBED_MODEL"]
index = os.environ.get("HANDBOOK_INDEX", "handbook")
top_k = int(os.environ.get("HANDBOOK_TOP_K", "3"))
embedded = _post(
embed_base + "/embeddings",
{"model": embed_model, "input": question},
os.environ["HANDBOOK_EMBED_API_KEY"],
)
vector = embedded["data"][0]["embedding"]
found = _post(
"%s/v1/projects/%s/indexes/%s:query"
% (vectordb_api, os.environ["CAI_PROJECT_ID"], index),
{"vector": vector, "top_k": top_k, "with_payload": True},
os.environ["VECTORDB_TOKEN"],
)
hits = found.get("results", [])
if not hits:
return "No matching handbook passage found."
return "\n\n".join(
"[%s] %s" % (hit.get("payload", {}).get("source", "handbook"),
hit.get("payload", {}).get("text", ""))
for hit in hits
)
handbook_bot = Agent(
role="Handbook Bot",
goal="Answer questions about the company handbook, and cite the file.",
backstory="A careful assistant that never guesses at policy.",
llm=crusoe.foundry_model(),
tools=[search_handbook],
verbose=False,
)
respond = Task(
description=(
"Prior conversation (may be empty on the first turn):\n{history}\n\n"
"Answer the user's current message using ONLY passages returned by "
"search_handbook, and name the source file. If they do not contain "
"the answer, say so plainly instead of guessing.\n\n{message}"
),
expected_output="A grounded answer naming the source file, or a plain admission that the handbook does not cover it.",
agent=handbook_bot,
)
crew = Crew(agents=[handbook_bot], tasks=[respond], process=Process.sequential)
PYEOF
Use @tool on a module-level function, not a BaseTool subclass. That is CrewAI's own documented shape for a custom tool, but on this platform a BaseTool subclass stops the agent from starting — see CrewAI. Note also that the task description carries the grounding instruction here, because that is where CrewAI puts the per-turn prompt.
mkdir -p rag-chatbot && cat > rag-chatbot/graph.py <<'PYEOF'
import json
import os
import urllib.request
from langchain_core.tools import tool
from langgraph.prebuilt import create_react_agent
import crusoe_langchain as crusoe
def _post(url, payload, token):
request = urllib.request.Request(
url,
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json",
"Authorization": "Bearer " + token},
)
with urllib.request.urlopen(request, timeout=30) as response:
return json.load(response)
@tool
def search_handbook(question: str) -> str:
"""Search the company handbook for passages that answer a question."""
# Read configuration HERE, at call time - never at import. See below.
# Each platform address is injected; the HANDBOOK_* names only override it.
vectordb_api = (os.environ.get("VECTORDB_API")
or os.environ["CAI_VECTORDB_URL"]).rstrip("/")
embed_base = (os.environ.get("HANDBOOK_EMBED_BASE_URL")
or os.environ["EMBED_BASE_URL"]).rstrip("/")
embed_model = os.environ.get("HANDBOOK_EMBED_MODEL") or os.environ["EMBED_MODEL"]
index = os.environ.get("HANDBOOK_INDEX", "handbook")
top_k = int(os.environ.get("HANDBOOK_TOP_K", "3"))
embedded = _post(
embed_base + "/embeddings",
{"model": embed_model, "input": question},
os.environ["HANDBOOK_EMBED_API_KEY"],
)
vector = embedded["data"][0]["embedding"]
found = _post(
"%s/v1/projects/%s/indexes/%s:query"
% (vectordb_api, os.environ["CAI_PROJECT_ID"], index),
{"vector": vector, "top_k": top_k, "with_payload": True},
os.environ["VECTORDB_TOKEN"],
)
hits = found.get("results", [])
if not hits:
return "No matching handbook passage found."
return "\n\n".join(
"[%s] %s" % (hit.get("payload", {}).get("source", "handbook"),
hit.get("payload", {}).get("text", ""))
for hit in hits
)
graph = create_react_agent(
crusoe.foundry_model(),
tools=[search_handbook],
prompt=(
"You answer questions about the company handbook. Always call "
"search_handbook first, and answer only from the passages it returns. "
"Name the source file you used. If the passages do not contain the "
"answer, say so plainly instead of guessing."
),
)
PYEOF
Use @tool on a module-level, synchronous function. A class-based tool or an async def body cannot be relocated into a sandbox and fails at startup — see LangGraph. That does not bite in this tutorial, because Act 7 turns sandboxing off deliberately, but it is the habit to keep.
Four things are true of all three versions:
- Only the standard library.
urllib.requestandjsonship with Python, so there is norequirements.txtto get wrong. CAI_PROJECT_IDis injected. The platform sets it on every workload, so the agent knows which project's index to query without you hard-coding a UUID.- The platform addresses are injected too.
CAI_VECTORDB_URLandEMBED_BASE_URL/EMBED_MODELarrive on every agent, pointing at internal platform addresses — which are not the public addresses your laptop uses.CAI_VECTORDB_URLis locked against being overridden, which is why an override has to have a name of its own; the code above takesVECTORDB_API,HANDBOOK_EMBED_BASE_URLandHANDBOOK_EMBED_MODELwhen you set them and the injected values when you do not. - Configuration is read inside the tool, at call time — never at import. That is not a style preference. A deploy builds the revision before any of Act 7's configuration exists, so a module-level
os.environ["VECTORDB_API"]raisesKeyErrorwhile the harness is importing your file. The container dies during startup and restarts into the same failure, so the deploy never reachesready: it ends asfailed, or times out after five minutes, and theKeyErroritself is only visible inplatformctl logs rag-chatbot --history. Read it at call time and the first revision starts cleanly, with the tool the only thing that fails until you finish Act 7.
Deploy it:
- platformctl
- curl
- Console
platformctl deploy ./rag-chatbot --name rag-chatbot
You should see:
packaging ./rag-chatbot...
uploading rag-chatbot (1.9 KiB, framework=adk)...
build 7d41e0b2-...-uuid accepted
state: -> building
state: building -> deploying
state: deploying -> ready
rag-chatbot is ready at https://rag-chatbot-ab12cd.apps.codyhill.dev
tar -czf rag-chatbot.tar.gz -C rag-chatbot .
curl -s -X POST "$CAI_API/v1/agents" \
-H "Authorization: Bearer $CAI_TOKEN" \
-F "name=rag-chatbot" \
-F "framework=adk" \
-F "code=@rag-chatbot.tar.gz"
Use framework=crewai or framework=langgraph to match the tab you wrote above. Poll state until it settles — the same state / ready pair the index reported in Act 3:
curl -s -H "Authorization: Bearer $CAI_TOKEN" \
"$CAI_API/v1/agents/rag-chatbot" | jq -r '.state, .message'
Go to Compute → Agents → Deploy agent, name it rag-chatbot, pick the framework you wrote above, and upload the rag-chatbot folder. Click through Review & deploy and watch the build panel reach ready.
It is deployed but not yet configured. The addresses it needs are already injected; the credentials it needs are not, so the tool fails on its first call. That is the next act — and note that the agent reached ready regardless, because nothing outside the tool touches configuration.
At this point you have: a running agent whose one tool will fail until you wire it up.
Act 7: wire in the credentials and settings
Three values go in by the secret route (never readable back) and two by the env route (readable). No address is among them: the agent gets internal VectorDB and inference addresses injected, and those are the ones that route from inside the platform.
The service account key does double duty. Both calls the tool makes — VectorDB and embeddings — are platform APIs that authenticate the same credential, so one key covers both.
- platformctl
- curl
- Console
Set the secrets first:
platformctl secrets set rag-chatbot \
VECTORDB_TOKEN="$SA_KEY" \
HANDBOOK_EMBED_API_KEY="$SA_KEY" \
TOOL_SANDBOX=false
You should see:
set 3 secret(s) for rag-chatbot
Then the non-secret settings:
platformctl agents env set rag-chatbot \
HANDBOOK_INDEX="$INDEX" \
HANDBOOK_EMBED_MODEL="$EMBED_MODEL"
Set the secrets first. PATCH merges, so it leaves any other secret alone:
curl -s -X PATCH "$CAI_API/v1/agents/rag-chatbot/secrets" \
-H "Authorization: Bearer $CAI_TOKEN" -H 'Content-Type: application/json' \
-d "{\"set\":{\"VECTORDB_TOKEN\":\"$SA_KEY\",\"HANDBOOK_EMBED_API_KEY\":\"$SA_KEY\",\"TOOL_SANDBOX\":\"false\"}}"
You should see:
{"agent":"rag-chatbot","secrets_updated":true,"keys":["HANDBOOK_EMBED_API_KEY","TOOL_SANDBOX","VECTORDB_TOKEN"]}
Then the non-secret settings:
curl -s -X PATCH "$CAI_API/v1/agents/rag-chatbot/env" \
-H "Authorization: Bearer $CAI_TOKEN" -H 'Content-Type: application/json' \
-d "{\"set\":{\"HANDBOOK_INDEX\":\"$INDEX\",\"HANDBOOK_EMBED_MODEL\":\"$EMBED_MODEL\"}}"
You should see:
{"agent":"rag-chatbot","env_updated":true,"env":{"HANDBOOK_INDEX":"handbook","HANDBOOK_EMBED_MODEL":"qwen-embedding"}}
Open the agent's Secrets and environment section. Add VECTORDB_TOKEN and HANDBOOK_EMBED_API_KEY as secrets, and HANDBOOK_INDEX and HANDBOOK_EMBED_MODEL as environment variables.
TOOL_SANDBOX cannot be set from the consoleThe third secret is the one the console will not take, and the two forms refuse it for different reasons. As plain configuration the API refuses it: PATCH /env answers 400, because the name is reserved and a value stored there would be discarded when the workload deploys anyway. On the secret form, where it would take effect, the console refuses it by name before the request leaves your browser — turning the sandbox off is not something to do by typing a variable into a list. Finish this act with the platformctl or curl tab:
platformctl secrets set rag-chatbot TOOL_SANDBOX=false
Read the next section before you run it.
HANDBOOK_EMBED_BASE_URL at the public APIIt is tempting to reuse $EMBED_BASE_URL from your shell here. Do not: that is https://api.codyhill.dev/v1, and the public hostname resolves back to this platform's own public front door — a hairpin that does not route from inside a project. Measured from a running agent, api.codyhill.dev:443 times out while inference-api on its internal address answers. What you see is a 30-second hang inside the tool, not an error naming the address. Leave it unset and the injected EMBED_BASE_URL is used: the same qwen-embedding your loader called from the public side, so the vectors line up.
A third-party provider is a different matter and is fine to set here — an agent may reach the public internet. It is this platform's own public hostname, specifically, that an agent cannot use.
Why TOOL_SANDBOX=false is needed here
By default the platform runs your own tool code in a single-use sandbox, which is one throwaway container. It is built from your agent's image, but every environment variable is stripped out and private network addresses are blocked. The empty environment is the security boundary. A tool that has been talked into misbehaving by a crafted prompt cannot read your keys or reach internal services.
This tutorial's tool needs exactly the two things that boundary removes: environment variables (the credentials) and an internal address (the VectorDB API). So it has to run inside the agent itself, next to those credentials. That is what TOOL_SANDBOX=false means. Turn the sandbox off only for tool code you wrote and fully trust, as here. The full picture is in built-in tools.
Why changes are not instant
Every secret, environment, or configuration change creates a brand-new revision — a frozen snapshot of the agent. Traffic moves to it once it starts. A running revision never changes underneath you. Both commands above rolled a new one, so wait for the newest to be serving:
platformctl status rag-chatbot
You should see state ready and ready true. platformctl status does not print the revision name. To confirm which revision is serving, call GET /v1/agents/rag-chatbot and read latest_revision — it should end in -00003. Or open the agent's page in the console. See traffic and revisions.
platformctl secrets set writes a value onto one agent, with no versions and no sharing. Do several workloads use the same credential? Store it once in the project secrets manager and bind it to each agent by name. You get versions, rotation, and an audit trail. See use secrets in workloads.
At this point you have: a fully configured RAG agent.
Act 8: ask it things
Start with a question the documents answer:
- platformctl
- curl
- Console
platformctl invoke rag-chatbot "How many days of paid time off do I get, and how many roll over?"
You should see:
You get 25 days of paid time off each year, and up to 5 unused days roll over into the next year. (Source: pto.md)
(session: 4b8e1f60-2c7a-4d95-8e13-9a0c5b2d7e41)
tool_call: search_handbook called with args={'question': 'paid time off days roll over'}
curl -s -X POST "$CAI_API/v1/agents/rag-chatbot/invoke" \
-H "Authorization: Bearer $CAI_TOKEN" \
-H 'content-type: application/json' \
-d '{"message":"How many days of paid time off do I get, and how many roll over?"}' \
| jq -r '.output, .tool_calls[].name'
You should see:
You get 25 days of paid time off each year, and up to 5 unused days roll over into the next year. (Source: pto.md)
search_handbook
Invoking is gated by INVOKE_AUTH_REQUIRED, which is on by default — without that header the reply is a 401 naming the flag. An install can open its data plane; do not assume yours has.
Open the agent's Test tab and ask:
How many days of paid time off do I get, and how many roll over?
You should see the answer, a search_handbook tool call entry, and the source file named in the reply.
The tool_call line is your receipt that retrieval actually happened. Now ask something phrased with none of the words in the document:
Do I need permission before buying a 900 dollar monitor?
You should see:
Yes. Anything above 500 dollars needs written manager approval before you buy it. (Source: expenses.md)
Note that expenses.md never says "monitor" or "permission". Vector search matched on meaning. That is the whole reason this works better than keyword search.
Now the most important test. Ask something the documents do not cover:
What is the parental leave policy?
You should see a reply saying the handbook does not cover parental leave, rather than an invented policy. Two things produce that behavior: one sentence in the agent's instruction ("say so plainly instead of guessing"), and the fact that the model can see only the retrieved passages. A RAG system that cannot say "I don't know" is worse than no RAG system.
At this point you have: a working, grounded, citing chatbot over your own documents.
Act 9: change a document and watch the answer change
Retrieval reads live data. So edit a document, re-run the loader, and the next answer changes — with no redeploy:
cat > handbook/pto.md <<'EOF'
Paid time off. Every employee gets 30 days of paid time off each year.
Unused days roll over into the next year, up to a maximum of 10 days.
Request time off at least two weeks in advance.
EOF
python3 ingest.py
platformctl invoke rag-chatbot "How many PTO days do I get?"
You should see the new number, 30. The agent's image, revision, and code are untouched. Only the index changed. That separation is the practical argument for RAG over baking knowledge into a model: your documents change hourly, and model builds do not.
Clean up
Delete everything you created, in this order: the agent, then the index, then the service account.
- platformctl
- curl
- Console
platformctl delete rag-chatbot
platformctl vectordb delete "$INDEX"
platformctl service-accounts delete handbook-reader
# 1. The agent
curl -s -X DELETE "$CAI_API/v1/agents/rag-chatbot" \
-H "Authorization: Bearer $CAI_TOKEN"
# 2. The index and every vector in it (project admin; there is no undo)
curl -sX DELETE "$VDB/v1/projects/$CAI_PROJECT/indexes/$INDEX" \
-H "Authorization: Bearer $CAI_TOKEN" -w '%{http_code}\n'
# 3. The service account (its keys are revoked in the same transaction)
curl -s -X DELETE "$CAI_API/v1/projects/$CAI_PROJECT/service-accounts/handbook-reader" \
-H "Authorization: Bearer $CAI_TOKEN"
You should see:
{"agent":"rag-chatbot","deleted":true}
204
{"deleted":true,"service_account":"handbook-reader@ab12cd.cai.local","note":"every key it held was revoked in the same transaction; the name stays reserved"}
- On the agent's page, click Delete and confirm.
- Under Data services → VectorDB, open the
handbookindex and click Delete index. - Under Security → Service accounts, open
handbook-readerand click Delete account.
Deleting an index destroys every vector in it. There are no snapshots and no restore. Service account names stay reserved forever, even after deletion, so a new identity can never inherit an old one's access.
If something breaks
| Symptom | Cause and fix |
|---|---|
index is not ready yet; its collection has not been created | You wrote points before the index finished being created. Poll GET .../indexes/handbook until "ready": true. |
point 0 has 1536 dimensions; index "handbook" expects 4096 | Your embedding model's output width does not match the index. Re-run python3 ingest.py dims, then create a new index with that number — dimensions cannot be edited. |
query vector has 1536 dimensions; index "handbook" expects 4096 | The agent and the loader are using different embedding models. They must use the same one. |
| Search returns nothing relevant, but points exist | Almost always the same cause as above: loaded with one model, queried with another. Confirm HANDBOOK_EMBED_MODEL on the agent matches EMBED_MODEL in your shell. |
| The tool hangs for 30 seconds and then reports a timeout | An address you set on the agent does not route from inside the platform — almost always HANDBOOK_EMBED_BASE_URL or VECTORDB_API pointed at the public API. Remove it and let the injected EMBED_BASE_URL / CAI_VECTORDB_URL be used. |
404 from a VectorDB call you believe should work | 404 means "does not exist or is not yours". The two are deliberately indistinguishable. Check the project UUID and the index name. |
The deploy never reaches ready, and platformctl logs rag-chatbot --history shows a KeyError | Your code reads an environment variable at import time. Act 6's deploy happens before Act 7's configuration exists, so the harness dies importing your file and the revision crash-loops. Read configuration inside the tool, as the examples do. |
The tool errors with KeyError on an environment variable | The agent is still serving an older revision, or TOOL_SANDBOX was never set to false. A sandboxed tool call has an empty environment, so every variable lookup fails. Check platformctl status rag-chatbot, and set the switch by the secret route — platformctl secrets set rag-chatbot TOOL_SANDBOX=false; the env route answers 400 for that name. |
403 this action requires the project admin role | Creating a service account and deleting an index are admin actions. Ask a project admin. |
The agent answers without ever calling search_handbook | Strengthen the instruction ("Always call search_handbook first"), and check the tool_calls list on each reply to confirm. |
409 an index with that name already exists | You already created it. Move on, or pick another name. |
More: Agent troubleshooting and VectorDB search.
What you learned
| Idea | The one-sentence version |
|---|---|
| Embedding | A list of numbers that captures a text's meaning; similar meanings sit close together. |
| Index | A named container of vectors with one fixed width and one distance metric, both permanent. |
| Point | One vector plus a JSON payload; the payload is what you show the user. |
| Chunk | The unit you embed and retrieve — also the unit of context the model reads. |
| RAG | Retrieve first, then let the model answer from what you retrieved. |
| Grounding | Instructing the model to use only the retrieved passages, and to admit when they fall short. |
| Service account | A project-scoped machine identity, so a workload never carries a person's credential. |
| Revision | An immutable snapshot; every secret or config change makes a new one. |
Next steps
- Indexes and points — ids, payloads, quantization, and what is immutable.
- Search — payload filters, score thresholds, and browsing points.
- Use VectorDB with agents — how this compares with the built-in memory bank.
- Tutorial: research agent with memory — the other kind of agent memory.
- Tutorial: weather tools over MCP — publish a tool once, share it across agents, and keep its credentials off the agent entirely.
- VectorDB API reference — every endpoint, field, and error.
Go deeper
These advanced guides pick up where the quickstarts stop, each exercising a different slice of the platform:
| Guide | Framework / language |
|---|---|
| Multi-step research agent | LangGraph |
| Editorial pipeline with a crew | CrewAI |
| Support agent over your own docs | ADK |
| Document ingestion pipeline | Python |
| Webhook fan-out, exactly once | Node.js |
| Scheduled reconciliation job | Go |
| Object-store ETL with move-after-read | Ruby |