Tutorial: Agentic AI Assistant with pgvector and OpenBao¶
What you'll build¶
In this guide we will build an internal company assistant that decides for itself which tools to call, runs them against your own data, and remembers what each employee told it in earlier sessions.
- An employee says "Remember that I work in the Helsinki office. Book a room for 4 people tomorrow at 10." The assistant saves the office and books the smallest free room that fits, in two steps it works out for itself.
- In a later session they ask "How many days a week can I work remotely?" The assistant searches the company policies, finds that the answer differs by office, and gives the Helsinki answer, because it remembered where they work.
- They ask for a room for 8 when the only one that size is taken. The booking fails, and the assistant explains why and offers another time instead of booking something they did not ask for.
The model never touches your systems directly. It only asks your code to call a tool, and says which details to use, such as the office and the time. Your code calls the tool with its own database password and gives the result back, so the model never sees the password.
Architecture¶
question, user id
|
v
+------------------+ 1. messages, tools +----------------------+
| | -------------------> | agent-llm |
| agent.py | <------------------- | Qwen3-8B on vLLM |
| the agent loop | 2. answer, or tool +----------------------+
| | calls to run Serverless Container
| |
| | 3. embed text +----------------------+
| tools.py | -------------------> | embedder |
| search_docs | <------------------- | Qwen3-Embedding-0.6B |
| book_room | vectors +----------------------+
| remember, recall | Serverless Container
| |
| | 4. read, write +----------------------+
| | -------------------> | Postgres + pgvector |
| | <------------------- | items, rooms, |
| | rows | bookings, memories |
+--------+---------+ +----------------------+
| same CPU instance
v
answer
In the diagram, agent.py runs the agent loop and tools.py holds the tools, and the
numbers follow one round of the loop. The loop sends the conversation and the tool list to
the model (1), which replies with an answer or with tool calls (2). For each call, agent.py
runs the matching tool, which embeds text (3) or reads and writes the database (4), and adds
the result to the conversation. This repeats until the model answers or the loop reaches its
step limit.
| Component | Tier | Runs when |
|---|---|---|
agent.py and tools.py | The Vector DB guide's CPU.4V.16G instance | While you run them |
| Postgres + pgvector | The same instance, with its Block Volume | Continuously, while the instance is on |
agent-llm | Serverless Container, L40S | On demand, scales to zero when idle |
embedder | Serverless Container, L40S | On demand, scales to zero when idle |
| OpenBao | The secrets guide's bao instance | Continuously, from Step 6 on |
The assistant has two kinds of memory. Short-term memory is the message list inside one
run, which holds the question, every tool call and every result, and is gone when the run
ends. Long-term memory is the memories table, which stores facts about each employee as
vectors, so that a later run can find the ones related to a new question by meaning rather
than by matching words.
The agent runs on the same instance as Postgres, so the database only accepts connections from that instance and is never exposed to the internet.
Prerequisites¶
- Complete Steps 1 to 5 of the Vector DB guide, so you have
Postgres with pgvector, the
embedderdeployment andindex.py. This guide builds on all three. - Complete Steps 1 to 4 of the secrets guide, so you have
OpenBao running on the
baoinstance. Only the last step of this guide uses it, to load the credentials. - Have ready your Inference API Key. A Hugging Face token is optional: both models in this guide are publicly downloadable, though unauthenticated requests are rate-limited more strictly than token-authenticated ones.
Everything here is done in the Verda console and over SSH.
Step 1: Deploy a tool-calling model¶
Create a deployment in Containers → New deployment:
| Field | Value |
|---|---|
| Deployment name | agent-llm |
| Container image | docker.io/vllm/vllm-openai:v0.27.1, the same image as the embedder |
| Exposed HTTP port and healthcheck port | 8000, the port vLLM listens on |
| Healthcheck path | /health, the path vLLM exposes |
| Start command | Toggle on, which reveals the Entrypoint and CMD fields |
| Entrypoint | Leave empty, so the image's own entrypoint is used |
| CMD | Qwen/Qwen3-8B --served-model-name agent-llm --max-model-len 16384 --enable-auto-tool-choice --tool-call-parser hermes |
| Environment variables | HF_TOKEN=<your-hugging-face-token> |
| GPU type | L40S 48GB, which is enough for this model. Use RTX PRO 6000 if no L40S is available |
--enable-auto-tool-choice lets the model decide on each turn whether to call a tool or to
answer, and --tool-call-parser hermes turns the tool-call format Qwen3 was trained on into
the tool_calls field of the response. --max-model-len 16384 caps the context so it fits in
GPU memory, while leaving room for several rounds of tool results.
The first start can take up to about 10 minutes, because about 16 GB of weights are pulled
and loaded.
Until then, the system log shows several Startup probe failed: connection refused entries.
Those are expected, because nothing is listening on the port yet.
Once the deployment reports healthy, copy its Endpoint Address from the deployment page.
SSH into the Vector DB guide's instance. Environment variables last only for the current
shell, so set the ones this guide uses now, and again whenever you reconnect. Replace each
placeholder, including the angle brackets, and leave off the / that the Endpoint Address
ends with:
Check that the model returns a tool call when it is offered a tool. Keep the \ at the end
of each line when you paste the command, or the shell splits it in the wrong places:
curl -sL -X POST "$AGENT_LLM_URL/chat/completions" --max-time 600 \
--header "Authorization: Bearer $VERDA_INFERENCE_KEY" \
--header 'Content-Type: application/json' \
--data '{"model": "agent-llm",
"messages": [{"role": "user", "content": "What is the travel policy?"}],
"tools": [{"type": "function", "function": {"name": "search_docs",
"description": "Search the company policies",
"parameters": {"type": "object",
"properties": {"query": {"type": "string"}}, "required": ["query"]}}}],
"chat_template_kwargs": {"enable_thinking": false}}' \
| python3 -m json.tool | grep -A6 '"tool_calls": \['
The model is working if the response contains a search_docs call with a query it wrote
itself:
"tool_calls": [
{
"id": "chatcmpl-tool-...",
"type": "function",
"function": {
"name": "search_docs",
"arguments": "{\"query\": \"travel policy\"}"
The id will differ in your output.
enable_thinking: false stops Qwen3 from writing out its reasoning before every reply, which
in a loop that calls the model several times per question adds latency to each call.
Step 2: Load the company policies¶
The policies will go into the Vector DB guide's items table.
First, remove its five sample help articles, so they do not come back when the assistant searches the policies.
Connect as the postgres superuser:
Leave psql with \q.
The assistant needs some company policies to search. This guide uses four short sample
policies, on remote work, travel, meeting rooms and laptops, so you can check each answer
against a text you can see. The remote work policy gives different rules for the Helsinki
and Berlin offices, which is how the guide later shows memory changing an answer.
Save them as policies.py in your home directory, next to index.py:
# policies.py
from index import index_items
POLICIES = [
{"item_id": "remote-work", "title": "Remote work",
"body": "Helsinki office: up to 3 remote days a week. Berlin office: up to 2 "
"remote days a week. Agree the days with your manager.",
"category": "people", "source_url": "/policies/remote-work"},
{"item_id": "travel", "title": "Travel expenses",
"body": "Book economy class for flights under 6 hours. Hotels up to 150 EUR a "
"night. Submit receipts within 30 days of returning.",
"category": "finance", "source_url": "/policies/travel"},
{"item_id": "meeting-rooms", "title": "Meeting rooms",
"body": "Book rooms through the assistant. Bookings are limited to 2 hours. "
"Cancel bookings you no longer need.",
"category": "office", "source_url": "/policies/meeting-rooms"},
{"item_id": "laptops", "title": "Laptops and equipment",
"body": "New laptops are replaced every 3 years. Report a lost or stolen laptop "
"to IT within 24 hours.",
"category": "it", "source_url": "/policies/laptops"},
]
if __name__ == "__main__":
index_items(POLICIES)
Activate the virtual environment and run it:
Step 3: Create the rooms, bookings and memories¶
Connect as the postgres superuser again:
Then, in that session, create the three tables:
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE rooms (
room_id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
office TEXT NOT NULL,
capacity INT NOT NULL
);
CREATE TABLE bookings (
booking_id BIGSERIAL PRIMARY KEY,
room_id INT NOT NULL REFERENCES rooms,
user_id TEXT NOT NULL,
during TSRANGE NOT NULL,
EXCLUDE USING gist (room_id WITH =, during WITH &&)
);
CREATE TABLE memories (
memory_id BIGSERIAL PRIMARY KEY,
user_id TEXT NOT NULL,
fact TEXT NOT NULL,
embedding vector(1024) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX memories_user_idx ON memories (user_id);
The EXCLUDE line is what stops double bookings: Postgres refuses any booking whose room is
the same and whose time range overlaps (&&) one already stored, even when two requests
arrive at the same moment. btree_gist is the extension that lets one constraint compare a
room id and a time range together.
memories stores each fact with its embedding, so facts can be found by meaning, and only
for the employee they belong to.
Add three sample rooms for the assistant to book. Run this once only, because running it again adds a second set:
INSERT INTO rooms (name, office, capacity) VALUES
('Meeting room 1', 'Helsinki', 4),
('Meeting room 2', 'Helsinki', 8),
('Meeting room 3', 'Berlin', 6);
Then create one role for the agent, with exactly the access its tools need. Replace
<agent-password>, including the angle brackets:
CREATE ROLE agent_rw LOGIN PASSWORD '<agent-password>';
GRANT SELECT ON items, rooms TO agent_rw;
GRANT SELECT (room_id, during), INSERT ON bookings TO agent_rw;
GRANT SELECT, INSERT ON memories TO agent_rw;
GRANT USAGE ON SEQUENCE bookings_booking_id_seq, memories_memory_id_seq TO agent_rw;
The agent can read policies but not change them, and it can see when rooms are taken but not
who took them, because it can read only the room and time columns of bookings.
Leave psql with \q, then set the role's address, using the same password:
Step 4: Write the tools¶
This step runs on the Vector DB guide's instance, in the ~/vecenv virtual environment you
activated in Step 2. If you have reconnected since, activate it again with
source ~/vecenv/bin/activate.
Each tool is a Python function plus a description the model reads to decide when to use it. The tools here are examples that use your sample tables, and in your own assistant they would call your real systems.
Add the OpenAI client:
Save this as tools.py in your home directory:
# tools.py
import os, psycopg
from datetime import datetime, timedelta
from index import embed # the Vector DB guide's embedding function
db = psycopg.connect(os.environ["PG_AGENT_DSN"], autocommit=True)
def search_docs(user_id: str, query: str) -> list[dict]:
qvec = embed([query])[0].tolist()
rows = db.execute(
"""SELECT title, body FROM items
ORDER BY embedding <=> %s::vector LIMIT 3""",
(qvec,),
).fetchall()
return [{"title": t, "body": b} for t, b in rows]
def book_room(user_id: str, office: str, people: int, start: str,
minutes: int = 60) -> dict:
if not 15 <= minutes <= 120:
raise ValueError("bookings must be between 15 and 120 minutes")
begin = datetime.fromisoformat(start)
end = begin + timedelta(minutes=minutes)
room = db.execute(
"""SELECT room_id, name FROM rooms r
WHERE office = %s AND capacity >= %s
AND NOT EXISTS (SELECT 1 FROM bookings b
WHERE b.room_id = r.room_id
AND b.during && tsrange(%s, %s))
ORDER BY capacity LIMIT 1""",
(office, people, begin, end),
).fetchone()
if room is None:
raise ValueError(f"no free room for {people} people in {office} "
f"from {begin:%H:%M} to {end:%H:%M}")
db.execute(
"INSERT INTO bookings (room_id, user_id, during) VALUES (%s, %s, tsrange(%s, %s))",
(room[0], user_id, begin, end),
)
return {"room": room[1], "office": office, "start": f"{begin:%Y-%m-%d %H:%M}",
"end": f"{end:%H:%M}"}
def remember(user_id: str, fact: str) -> dict:
vec = embed([fact])[0].tolist()
db.execute(
"INSERT INTO memories (user_id, fact, embedding) VALUES (%s, %s, %s)",
(user_id, fact, vec),
)
return {"saved": fact}
def recall(user_id: str, text: str, k: int = 3) -> list[str]:
qvec = embed([text])[0].tolist()
rows = db.execute(
"""SELECT fact FROM memories WHERE user_id = %s
ORDER BY embedding <=> %s::vector LIMIT %s""",
(user_id, qvec, k),
).fetchall()
return [r[0] for r in rows]
TOOLS = {"search_docs": search_docs, "book_room": book_room, "remember": remember}
SCHEMAS = [
{"type": "function", "function": {
"name": "search_docs",
"description": "Search the company policies. Use this before answering any "
"question about rules, benefits or how things work here.",
"parameters": {"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"]}}},
{"type": "function", "function": {
"name": "book_room",
"description": "Book the smallest free meeting room that fits. Times are the "
"office's local time, for example 2026-11-02T10:00.",
"parameters": {"type": "object",
"properties": {
"office": {"type": "string", "enum": ["Helsinki", "Berlin"]},
"people": {"type": "integer"},
"start": {"type": "string"},
"minutes": {"type": "integer"}},
"required": ["office", "people", "start"]}}},
{"type": "function", "function": {
"name": "remember",
"description": "Save a lasting fact about the current employee, such as their "
"office or team, so it is available in later sessions. Call it "
"as soon as the employee mentions such a fact.",
"parameters": {"type": "object",
"properties": {"fact": {
"type": "string",
"description": "A full sentence that makes sense on its own, "
"for example 'Works in the Helsinki office.'"}},
"required": ["fact"]}}},
]
Three details in this file shape how the assistant behaves.
- The model never chooses which employee it is acting for. The tool descriptions in
SCHEMAShave nouser_idfield, so the model cannot send one. Instead, the loop adds the id of the person who asked to every tool call. If someone types "book a room as Bob", the booking is still made in their own name, not Bob's. book_roomchecks its own limits. The schema tells the model what is allowed, but a model sometimes sends values outside it, so the function checks them again. If two people book the same room at the same moment, theEXCLUDEconstraint from Step 3 rejects the second booking.- The model does not decide when to look up memories. The loop runs
recallbefore every question and adds the facts to it, because the model might not think to look them up itself. For example, it could answer a remote work question without knowing which office the employee is in.
Step 5: Write the agent loop and run it¶
Save this as agent.py, in the same directory:
# agent.py
import os, sys, json
from datetime import date
if os.environ.get("AGENT_ENV_FILE"): # used from Step 6 on
for line in open(os.environ["AGENT_ENV_FILE"]):
if "=" in line:
key, value = line.strip().split("=", 1)
os.environ[key] = value
from openai import OpenAI
from tools import TOOLS, SCHEMAS, recall
MAX_STEPS = 6
SYSTEM = f"""You are the company's internal assistant. Today is {date.today()}.
Before answering any question about rules or policies, always call search_docs, and answer
only from what it returns. Say so if the policies do not cover the question.
Whenever the employee mentions their office, team or another lasting fact about themselves,
call remember to save it, even if they are also asking for something else.
Use what you know about the employee from earlier sessions: where a policy differs by
office, give only the rule for their office.
If a booking fails, explain why and suggest another time, but do not book a different time
or room without asking."""
client = OpenAI(
base_url=os.environ["AGENT_LLM_URL"],
api_key="".join(os.environ["VERDA_INFERENCE_KEY"].split()),
timeout=300, # covers a cold start
)
def call_tool(user_id: str, call) -> str:
fn = TOOLS.get(call.function.name)
if fn is None:
return json.dumps({"error": f"no tool named {call.function.name}"})
try:
args = json.loads(call.function.arguments or "{}")
return json.dumps(fn(user_id, **args), default=str)
except Exception as e:
return json.dumps({"error": f"{type(e).__name__}: {e}"})
def run(user_id: str, question: str) -> str:
facts = recall(user_id, question)
if facts:
question = ("What you know about me from earlier sessions:\n"
+ "\n".join(f"- {f}" for f in facts)
+ f"\n\nMy question: {question}")
messages = [{"role": "system", "content": SYSTEM},
{"role": "user", "content": question}]
print("Tool calls:")
for step in range(1, MAX_STEPS + 1):
msg = client.chat.completions.create(
model="agent-llm",
messages=messages,
tools=SCHEMAS,
tool_choice="required" if step == 1 else "auto",
temperature=0,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
).choices[0].message
if not msg.tool_calls:
return msg.content
messages.append(msg.model_dump(exclude_none=True))
for call in msg.tool_calls:
result = call_tool(user_id, call)
short = result if len(result) <= 100 else result[:100] + "..."
print(f" [step {step}] {call.function.name} {call.function.arguments} -> {short}")
messages.append({"role": "tool", "tool_call_id": call.id, "content": result})
return f"I could not finish this in {MAX_STEPS} steps, so I stopped."
if __name__ == "__main__":
answer = run(sys.argv[1], " ".join(sys.argv[2:]))
print(f"\nAnswer:\n{answer}")
The loop is the whole agent, and four parts of it are worth a closer look.
MAX_STEPSstops the loop after six rounds. A model can get stuck calling tools without reaching an answer, and every call uses GPU time, so the loop gives up and says so.tool_choice="required"makes the first step use a tool. Every request to this assistant needs one, and without this a small model sometimes answers from nothing, saying the policies do not cover a question it never searched. After the first step the model chooses freely, so it can answer once it has the results.call_toolsends errors back to the model. When a tool fails or the model asks for one that does not exist, the model gets the error message instead of the run crashing, so it can try again.messages.append(msg.model_dump(...))keeps the model's tool requests in the conversation. Each result is linked to the request it answers, so the model needs to see both.
The AGENT_ENV_FILE lines at the top do nothing yet. Step 6 uses them to load the
credentials from a file instead of from your shell.
Run the assistant¶
Run these in your home directory, with ~/vecenv active and the variables from Steps 1 and
3 set. The model's wording will differ from these examples, and the dates will be your own
tomorrow, but the tool calls should match.
Start with a request that needs two tools: one to save where you work, and one to book a room:
python3 agent.py alice "Remember that I work in the Helsinki office. Book a room for 4 people tomorrow at 10."
Tool calls:
[step 1] remember {"fact": "Works in the Helsinki office."} -> {"saved": "Works in the Helsinki office."}
[step 2] book_room {"office": "Helsinki", "people": 4, "start": "2026-11-03T10:00", "minutes": 60} -> {"room": "Meeting room 1", "office": "Helsinki", "start": "2026-11-03 10:00", "end": "11:00"}
Answer:
Your room has been booked successfully! Meeting room 1 in the Helsinki office is reserved for you from 10:00 to 11:00 tomorrow. Let me know if you need any further assistance!
The assistant worked through the request in two steps on its own: it saved the fact, then booked the smallest free room in Helsinki.
Now run agent.py again, as a new conversation, and ask a question whose answer depends on
the office, without naming it:
Tool calls:
[step 1] search_docs {"query": "remote work policy for Helsinki office"} -> [{"title": "Remote work", "body": "Helsinki office: up to 3 remote days a week. Berlin office: up to...
Answer:
You can work remotely up to 3 days a week in the Helsinki office. Be sure to agree on the specific days with your manager.
recall found the fact from the first run and added it to the question, so the model
searched for the Helsinki policy and gave only the Helsinki rule.
Now ask the same question as a different employee, bob, who has not told the assistant
which office he works in:
Tool calls:
[step 1] search_docs {"query": "How many days a week can I work remotely?"} -> [{"title": "Remote work", "body": "Helsinki office: up to 3 remote days a week. Berlin office: up to...
Answer:
The number of remote work days per week depends on your office:
- **Helsinki office**: Up to 3 remote days a week.
- **Berlin office**: Up to 2 remote days a week.
You should agree on the specific days with your manager.
Bob gets the rules for both offices. Memories are saved per employee, so Alice's office is never used for Bob's questions.
Finally, test a booking that cannot be made. Meeting room 2 is the only Helsinki room for 8,
so first book it tomorrow at 10 as another employee, carol:
sudo -u postgres psql -d vectordb -c "INSERT INTO bookings (room_id, user_id, during) VALUES (2, 'carol', tsrange(current_date + interval '1 day 10 hours', current_date + interval '1 day 12 hours'));"
If you see conflicting key value violates exclusion constraint instead, Carol's booking
is already there from an earlier run, which is all this command is for, so carry on.
Then ask for it:
Tool calls:
[step 1] book_room {"office": "Helsinki", "people": 8, "start": "2026-11-03T10:00", "minutes": 60} -> {"error": "ValueError: no free room for 8 people in Helsinki from 10:00 to 11:00"}
Answer:
I'm sorry, but there are no free rooms available for 8 people in the Helsinki office at 10:00 tomorrow. Would you like to try a different time or room?
The tool failed, the loop passed the error back, and the model explained it and asked instead of booking a different time, as the system prompt tells it to.
Step 6: Load the credentials from OpenBao¶
So far the database password and the Inference API Key are typed into the shell, which also saves them in its history. This step moves them into OpenBao, and the OpenBao agent writes them to a file only you can read.
You need OpenBao running first. If you have not set it up yet, follow Steps 1 to 4 of the secrets guide, then come back here. Its later steps store a Hugging Face token, which this guide does not need.
On the bao instance, with BAO_ADDR and BAO_CACERT set as in the secrets guide's
Step 3, and logged in with your root token as in its Step 4, create a key-value store for
this guide's credentials, named agent:
Turn on AppRole logins, which the agent uses to sign in:
Then store both credentials. The address says localhost because the agent reads the
database from the same instance:
bao kv put agent/tools \
pg_dsn='postgresql://agent_rw:<agent-password>@localhost/vectordb' \
inference_key='<your-inference-api-key>'
== Secret Path ==
agent/data/tools
======= Metadata =======
Key Value
--- -----
created_time 2026-11-02T13:13:38.513582168Z
custom_metadata <nil>
deletion_time n/a
destroyed false
version 1
Save this as agent-policy.hcl, which allows reading that one path:
bao write auth/approle/role/agent \
token_policies="agent" token_ttl=20m token_max_ttl=1h secret_id_ttl=24h
Print the role id, a new secret id and the certificate, and save all three for the next part of this step:
Info
Save these values. Keep the secret id private: it is a credential, and it expires after 24 hours.
On the Vector DB guide's instance, install the same OpenBao release, which includes the agent:
curl -fsSL -o openbao.deb \
https://github.com/openbao/openbao/releases/download/v2.7.0/openbao_2.7.0_linux_amd64.deb
The agent needs four files in a folder called ~/bao-agent: the certificate, the role id and
the secret id you saved, and a settings file. Create the folder, readable only by you:
1. The certificate. Save the certificate you printed on the bao instance as
~/bao-agent/ca.pem, including its BEGIN and END lines.
2. The role id. Replace <role-id>, including the brackets, with the first value you
saved:
3. The secret id. Replace <secret-id> the same way, with the second value:
4. The settings. Save this as ~/bao-agent/agent.hcl. It tells the agent to log in
with the two AppRole files and write both credentials to /run/agent.env. /run is kept in
memory rather than on disk, and perms = "0600" makes the file readable by its owner only:
auto_auth {
method "approle" {
config = {
role_id_file_path = "/root/bao-agent/role_id"
secret_id_file_path = "/root/bao-agent/secret_id"
remove_secret_id_file_after_reading = true
}
}
}
template {
destination = "/run/agent.env"
perms = "0600"
contents = <<EOT
{{ with secret "agent/data/tools" }}
PG_AGENT_DSN={{ .Data.data.pg_dsn }}
VERDA_INFERENCE_KEY={{ .Data.data.inference_key }}
{{ end }}
EOT
}
Now start the OpenBao agent. It needs to keep running to keep the credentials file up to
date, so give it its own window. Open a new terminal window, SSH into the Vector DB guide's
instance, and run this there. Replace <bao-public-ip> with the bao instance's address:
BAO_ADDR='https://<bao-public-ip>:8200' BAO_CACERT=~/bao-agent/ca.pem \
bao agent -config=/root/bao-agent/agent.hcl
The credentials have arrived when the log shows the template being rendered:
==> OpenBao Agent started!
...
2026-11-02T13:39:24.510Z [INFO] agent.auth.handler: authentication successful, sending token to sinks
...
2026-11-02T13:39:24.515Z [INFO] agent: (runner) rendered "(dynamic)" => "/run/agent.env"
2026-11-02T13:39:24.520Z [INFO] agent.auth.handler: renewed auth token
The log keeps running after the last line, which is expected.
If the log shows a certificate error instead, save the certificate as ~/bao-agent/ca.pem
again and restart the agent.
Leave the agent running and go back to the window where you ran agent.py, on the Vector DB
guide's instance. Run the three commands below there. You do not need to edit any file,
because the lines at the top of agent.py already read the credentials file.
First, clear the two credentials you typed into this shell earlier:
Then tell agent.py where the agent wrote them:
Then ask the same question as in Step 5:
Tool calls:
[step 1] search_docs {"query": "remote work policy"} -> [{"title": "Remote work", "body": "Helsinki office: up to 3 remote days a week. Berlin office: up to...
Answer:
You can work remotely up to 3 days a week since you work in the Helsinki office. Be sure to agree on the specific days with your manager.
The same answer as before. To confirm the credentials now come from OpenBao rather than from your shell, check that the shell no longer holds them:
This prints an empty line. The assistant still answered, so it read the credentials from the file the OpenBao agent wrote.
Congratulations! You now have an assistant that chooses its own tools, acts on your data within the limits you set, remembers each employee between sessions, and loads its credentials from OpenBao. The rest of this page covers how to run it again later.
After it works¶
Running it again in a new session¶
Closing your SSH windows clears the environment variables and stops the OpenBao agent. Your
data stays: the policies, bookings and memories are in Postgres, and the agent's files are
in ~/bao-agent. To run the assistant again, open three terminal windows and SSH in from
each:
| Window | Connect to | Used for |
|---|---|---|
bao window |
The bao instance |
A new secret id |
| Agent window | The Vector DB guide's instance | The OpenBao agent, which keeps running |
agent.py window |
The Vector DB guide's instance | Running agent.py |
The two instances have different IP addresses. <bao-public-ip> below is always the bao
instance's, even in the commands you run on the Vector DB guide's instance.
In the bao window, set the server's address and certificate again, replacing
<bao-public-ip>:
If bao token lookup returns an error, log in again with bao login <root-token>. Then
generate a new secret id. The old one was deleted after the agent read it, and it expires
after 24 hours anyway:
If the bao instance has restarted since, OpenBao is sealed, so unseal it first as in the
secrets guide's Step 4.
In the agent window, save the new secret id, replacing the brackets:
Then start the agent, and wait for authentication successful in its log. You may not see a
rendered line this time: if /run/agent.env is still there from before with the same
values, the agent leaves it as it is.
BAO_ADDR='https://<bao-public-ip>:8200' BAO_CACERT=~/bao-agent/ca.pem \
bao agent -config=/root/bao-agent/agent.hcl
If the log repeats connect: connection refused instead, nothing is answering at that
address. Check the IP address you typed, and that OpenBao is running on the bao instance
with sudo systemctl status openbao. Press Ctrl+C to stop the agent before trying again.
In the agent.py window, set up the shell again. Only the model's address needs typing,
because the credentials come from the agent's file:
Then ask a question:
Tool calls:
[step 1] search_docs {"query": "remote work policy"} -> [{"title": "Remote work", "body": "Helsinki office: up to 3 remote days a week. Berlin office: up to...
Answer:
You can work remotely up to 3 days a week since you work in the Helsinki office. Be sure to agree on the specific days with your manager.
The answer is still the Helsinki one, because Alice's memory is stored in Postgres, not in the session.
Searching and answering from long documents with RAG¶
search_docs returns whole policies, which works for short texts. Long handbooks would fill
the model's context, so for those, follow the RAG guide: it
splits documents into chunks and returns only the passages that match, with their source, so
the assistant can say which handbook an answer came from.
To use it, replace the search inside the search_docs function with a call to the RAG
guide's retrieve(). Only that function changes: the tool's description and the agent loop
stay the same.
Conclusion¶
You now have an agent loop on Verda that calls a tool-calling model served as a Serverless Container, runs tools against your own Postgres, keeps long-term memory per employee in pgvector, and loads its credentials from OpenBao instead of your shell.
To clean up. Stop the OpenBao agent with Ctrl+C in its window, and delete the
agent-llm deployment. The embedder deployment, the CPU instance and its Block Volume
belong to the Vector DB guide, and the bao instance to the
secrets guide, so remove them as those guides describe if
you no longer need them.
Discontinue an instance
rather than shutting it down, which does not stop charges, and
delete the Block Volume separately, because it bills
for as long as it exists.
Related guides¶
- Vector database: the Postgres instance, the embedding deployment and the indexer this guide builds on.
- RAG application: answering from long documents, with chunking and citations.
- LLM API gateway: one endpoint in front of several model deployments, with per-app keys and failover.
- Secrets management: OpenBao setup, policies, AppRoles and rotation in full.