---
description: "Build an agentic application on Verda: an LLM decides which tools to call, searches your documents, books rooms through Postgres, and remembers facts about each employee using pgvector, with credentials loaded from OpenBao."
revision_date: 09.11.2026
---

# 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

<pre style="overflow-x: auto; white-space: pre;">

     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

</pre>

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.

<div style="display: block !important; width: 100% !important; margin: 0 !important;">
<table style="display: table !important; width: 100% !important; margin: 0 !important; table-layout: fixed;">
<thead>
<tr><th>Component</th><th>Tier</th><th>Runs when</th></tr>
</thead>
<tbody>
<tr><td><code>agent.py</code> and <code>tools.py</code></td><td>The Vector DB guide's CPU.4V.16G instance</td><td>While you run them</td></tr>
<tr><td>Postgres + pgvector</td><td>The same instance, with its Block Volume</td><td>Continuously, while the instance is on</td></tr>
<tr><td><code>agent-llm</code></td><td>Serverless Container, L40S</td><td>On demand, scales to zero when idle</td></tr>
<tr><td><code>embedder</code></td><td>Serverless Container, L40S</td><td>On demand, scales to zero when idle</td></tr>
<tr><td>OpenBao</td><td>The secrets guide's <code>bao</code> instance</td><td>Continuously, from Step 6 on</td></tr>
</tbody>
</table>
</div>

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](https://docs.verda.com/containers/tutorials/guide-vector-database/), so you have
  Postgres with pgvector, the `embedder` deployment and `index.py`. This guide builds on
  all three.
- Complete Steps 1 to 4 of the [secrets guide](https://docs.verda.com/containers/tutorials/guide-secrets-management/), so you have
  OpenBao running on the `bao` instance. 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](https://console.verda.com) 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:

```bash
export VERDA_INFERENCE_KEY='<your-inference-api-key>'
```

```bash
export AGENT_LLM_URL='<agent-llm-endpoint-address>/v1'
```

```bash
export PG_WRITE_DSN='postgresql://vec_write:<write-password>@localhost/vectordb'
```

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:

```bash
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:

```text {.no-copy}
"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:

```bash
sudo -u postgres psql -d vectordb
```

```sql
-- Deletes every row in items. If you loaded your own content there, skip this.
DELETE FROM items;
```

```text {.no-copy}
DELETE 5
```

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`:

```python title="policies.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:

```bash
source ~/vecenv/bin/activate
```

```bash
python3 policies.py
```

```text {.no-copy}
indexed 4/4
```

## Step 3: Create the rooms, bookings and memories

Connect as the `postgres` superuser again:

```bash
sudo -u postgres psql -d vectordb
```

Then, in that session, create the three tables:

```sql
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:

```sql
INSERT INTO rooms (name, office, capacity) VALUES
    ('Meeting room 1', 'Helsinki', 4),
    ('Meeting room 2', 'Helsinki', 8),
    ('Meeting room 3', 'Berlin',   6);
```

```text {.no-copy}
INSERT 0 3
```

Then create one role for the agent, with exactly the access its tools need. Replace
`<agent-password>`, including the angle brackets:

```sql
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:

```bash
export PG_AGENT_DSN='postgresql://agent_rw:<agent-password>@localhost/vectordb'
```

## 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:

```bash
pip install "openai==3.42.1"
```

Save this as `tools.py` in your home directory:

```python title="tools.py"
# 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
  `SCHEMAS` have no `user_id` field, 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_room` checks 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, the `EXCLUDE` constraint from Step 3 rejects the
  second booking.
- **The model does not decide when to look up memories.** The loop runs `recall` before 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:

```python title="agent.py"
# 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_STEPS` stops 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_tool` sends 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:

```bash
python3 agent.py alice "Remember that I work in the Helsinki office. Book a room for 4 people tomorrow at 10."
```

```text {.no-copy}
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:

```bash
python3 agent.py alice "How many days a week can I work remotely?"
```

```text {.no-copy}
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:

```bash
python3 agent.py bob "How many days a week can I work remotely?"
```

```text {.no-copy}
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`:

```bash
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'));"
```

```text {.no-copy}
INSERT 0 1
```

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:

```bash
python3 agent.py alice "Book a room for 8 people tomorrow at 10."
```

```text {.no-copy}
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](https://docs.verda.com/containers/tutorials/guide-secrets-management/), 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`:

```bash
bao secrets enable -path=agent kv-v2
```

```text {.no-copy}
Success! Enabled the kv-v2 secrets engine at: agent/
```

Turn on AppRole logins, which the agent uses to sign in:

```bash
bao auth enable approle
```

```text {.no-copy}
Success! Enabled approle auth method at: approle/
```

Then store both credentials. The address says `localhost` because the agent reads the
database from the same instance:

```bash
bao kv put agent/tools \
  pg_dsn='postgresql://agent_rw:<agent-password>@localhost/vectordb' \
  inference_key='<your-inference-api-key>'
```

```text {.no-copy}
== 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:

```hcl title="agent-policy.hcl"
path "agent/data/tools" {
  capabilities = ["read"]
}
```

```bash
bao policy write agent agent-policy.hcl
```

```text {.no-copy}
Success! Uploaded policy: agent
```

```bash
bao write auth/approle/role/agent \
  token_policies="agent" token_ttl=20m token_max_ttl=1h secret_id_ttl=24h
```

```text {.no-copy}
Success! Data written to: auth/approle/role/agent
```

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.

```bash
bao read -field=role_id auth/approle/role/agent/role-id
```

```bash
bao write -f -field=secret_id auth/approle/role/agent/secret-id
```

```bash
cat /etc/openbao/tls/cert.pem
```

**On the Vector DB guide's instance**, install the same OpenBao release, which includes the
agent:

```bash
curl -fsSL -o openbao.deb \
  https://github.com/openbao/openbao/releases/download/v2.7.0/openbao_2.7.0_linux_amd64.deb
```

```bash
sudo dpkg -i openbao.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:

```bash
mkdir -m 700 ~/bao-agent
```

**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:

```bash
printf '%s' '<role-id>' > ~/bao-agent/role_id
```

**3. The secret id.** Replace `<secret-id>` the same way, with the second value:

```bash
printf '%s' '<secret-id>' > ~/bao-agent/secret_id
```

**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:

```hcl title="~/bao-agent/agent.hcl"
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:

```bash
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:

```text {.no-copy}
==> 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:

```bash
unset PG_AGENT_DSN VERDA_INFERENCE_KEY
```

Then tell `agent.py` where the agent wrote them:

```bash
export AGENT_ENV_FILE=/run/agent.env
```

Then ask the same question as in Step 5:

```bash
python3 agent.py alice "How many days a week can I work remotely?"
```

```text {.no-copy}
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:

```bash
echo "$PG_AGENT_DSN"
```

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>`:

```bash
export BAO_ADDR='https://<bao-public-ip>:8200'
```

```bash
export BAO_CACERT='/etc/openbao/tls/cert.pem'
```

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:

```bash
bao write -f -field=secret_id auth/approle/role/agent/secret-id
```

If the `bao` instance has restarted since, OpenBao is sealed, so unseal it first as in the
[secrets guide](https://docs.verda.com/containers/tutorials/guide-secrets-management/)'s Step 4.

**In the agent window**, save the new secret id, replacing the brackets:

```bash
printf '%s' '<secret-id>' > ~/bao-agent/secret_id
```

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.

```bash
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:

```bash
source ~/vecenv/bin/activate
```

```bash
export AGENT_LLM_URL='<agent-llm-endpoint-address>/v1'
```

```bash
export AGENT_ENV_FILE=/run/agent.env
```

Then ask a question:

```bash
python3 agent.py alice "How many days a week can I work remotely?"
```

```text {.no-copy}
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](https://docs.verda.com/containers/tutorials/guide-rag-application/): 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](https://docs.verda.com/containers/tutorials/guide-vector-database/), and the `bao` instance to the
[secrets guide](https://docs.verda.com/containers/tutorials/guide-secrets-management/), so remove them as those guides describe if
you no longer need them.
[Discontinue](https://docs.verda.com/cpu-and-gpu-instances/shutdown-hibernate-and-delete/) an instance
rather than shutting it down, which does not stop charges, and
[delete](https://docs.verda.com/storage/deleting-storage/) the Block Volume separately, because it bills
for as long as it exists.

***

## Related guides

- [Vector database](https://docs.verda.com/containers/tutorials/guide-vector-database/): the Postgres instance, the embedding
  deployment and the indexer this guide builds on.
- [RAG application](https://docs.verda.com/containers/tutorials/guide-rag-application/): answering from long documents, with chunking
  and citations.
- [LLM API gateway](https://docs.verda.com/containers/tutorials/guide-llm-api-gateway/): one endpoint in front of several model
  deployments, with per-app keys and failover.
- [Secrets management](https://docs.verda.com/containers/tutorials/guide-secrets-management/): OpenBao setup, policies, AppRoles and
  rotation in full.
