Build a Local Inventory Chatbot
Build a local inventory chatbot with Langflow, Ollama, and Streamlit.
Introduction
30 Second Summary
A quick stock question can turn into a slow hunt through spreadsheet rows. Exact answers become harder to trust when the question involves filters or calculations.
In this project, you will build a local inventory assistant that answers everyday questions with exact results from your data. Your assistant uses Langflow to coordinate Ollama responses grounded in a SQLite snapshot before displaying them in Streamlit.
What You'll Build
Your finished Inventory Chat page answers everyday stock questions with exact results from your local inventory snapshot.
By the end of this project, you'll have:
- Ask about stock levels in everyday language. You receive exact database results instead of guesses.
- Watch the agent call RUN_SQL_QUERY in Langflow Playground. The visible trace proves that each answer comes from the inventory snapshot.
- Continue related inventory questions in one browser session. You can clear the conversation to start a fresh Langflow session.
- Secret Mission: expose the completed inventory flow as a reusable tool for another Langflow agent.
Are there any prerequisites?
You need an existing Streamlit inventory app. You also need an inventory dataset that you can export as CSV plus a running Langflow installation.
Before We Start
Before any setup begins, lock in the boundary of what you are building. Your chatbot will provide a read-only conversational view over an exported inventory CSV snapshot, using exact SQLite query results for counts, prices, categories, stock thresholds, and aggregate values while leaving the source CSV unchanged.
Prepare the Local Model and Inventory Snapshot
Exact inventory answers need evidence from your business data. The chatbot needs a private local model plus a queryable copy of the exported inventory.
The local model handles each conversation. A disposable SQLite snapshot supplies the exact rows for later filters and calculations.
In this step, you will prepare the existing Streamlit project. You will also verify Ollama before converting the inventory export into a database.
In this step, get ready to:
- Open the existing Streamlit project in Visual Studio Code.
- Run qwen3:4b locally with Ollama.
- Convert inventory.csv into a queryable SQLite snapshot.
Open the project and add support files
Visual Studio Code gives you one place to edit the importer and use PowerShell. Start by confirming that the editor is available on your Windows computer.
- Press the Windows key to open Windows Search.
- Type Visual Studio Code into the search field.
✔️ I see Visual Studio Code
- Press Enter to open Visual Studio Code.
- Click File in the top menu.
- Click Open Folder.
- Select the folder containing your existing Streamlit inventory app.
- Click Select Folder to open the project.
You should see the existing app files in the Explorer sidebar.
ⓧ Visual Studio Code is missing
The Windows User setup installs Visual Studio Code without requiring administrator permissions.
- Download the Windows User setup from the official Visual Studio Code page.
- Run the downloaded Windows User setup.
- Accept the default installation options.
- Restart PowerShell after the installation finishes.
- Move PowerShell to the folder containing your existing Streamlit inventory app.
- Open that folder in Visual Studio Code by running this command:
code .
What does this command do?
The code command launches Visual Studio Code. The full stop tells it to open the current project folder.
PowerShell cannot find code?
Close every PowerShell window after the installation. Open a fresh window so it can load the updated command path.
Still stuck? Help me make the code command available in Windows PowerShell
The project support files pin the app dependencies and keep local data out of source control. They also provide a safe template for the Langflow connection settings.
- Create requirements.txt inside the open Streamlit project folder.
- Add the pinned dependencies by copying this content into requirements.txt:
streamlit==1.66.0
requests==2.34.2
What do these dependencies provide?
- Streamlit 1.66.0 runs the existing inventory interface plus the chat page added later.
- Requests 2.34.2 sends chat prompts to the local Langflow flow.
- Save requirements.txt.
- Confirm that requirements.txt appears in the Explorer sidebar.
Missing the dependency file?
Check that the file sits beside the entry point for your existing Streamlit app. Remove any hidden .txt extension that Windows added to the name.
Still stuck? Help me create requirements.txt in my Streamlit project
- Create .gitignore inside the same Streamlit project folder.
- Protect local secrets and generated data by copying this content into .gitignore:
.streamlit/secrets.toml
*.db
__pycache__/
What does this file protect?
- The first entry excludes the local secrets file that later stores the Langflow API key.
- The second entry excludes generated SQLite database files.
- The final entry excludes Python bytecode caches.
- Save .gitignore.
- Confirm that all three exclusion patterns remain on separate lines.
Did the file lose its leading dot?
Rename the file inside the Visual Studio Code Explorer sidebar. Its complete name must be .gitignore.
Still stuck? Help me correct my .gitignore file name and patterns
- Create a folder named .streamlit inside the Streamlit project folder.
- Create secrets.toml.example inside the new .streamlit folder.
- Define the Langflow setting placeholders by copying this content into .streamlit\secrets.toml.example:
[langflow]
server_url = "http://localhost:7860"
flow_id = "your-flow-id-here"
api_key = "your-langflow-api-key-here"
Why use an example secrets file?
The example documents the three settings that the chat page needs. Its placeholders contain no live credential.
The real secrets.toml file stays local because .gitignore excludes it.
- Save .streamlit\secrets.toml.example.
- Confirm that the Explorer sidebar shows the example file inside .streamlit.
Is the example file in the wrong folder?
Drag secrets.toml.example into the .streamlit folder in the Explorer sidebar. The folder must sit directly inside the existing Streamlit project folder.
Still stuck? Help me place my Streamlit example secrets file correctly
- Create a folder named pages inside the Streamlit project folder.
You should now see requirements.txt, .gitignore, .streamlit, and pages in the project tree.
Run the local Ollama model
Ollama runs the language model on your Windows computer. This keeps model inference local while the SQLite snapshot remains on the same machine.
- Click Terminal in the Visual Studio Code menu bar.
- Click New Terminal to open PowerShell.
- Check the installed Ollama version by running this command:
ollama --version
What does this command check?
The command prints the installed Ollama version. Use that result to choose the matching path below.
✔️ I see v0.40.4 or higher
Ollama is ready to download and run the local model.
ⓧ I see an older version
Upgrade Ollama before loading the model so this project uses v0.40.4 or a newer release.
- Download OllamaSetup.exe from the official Ollama Windows page.
- Run OllamaSetup.exe to update the existing installation.
- Close the current PowerShell terminal after the installer finishes.
- Open a new PowerShell terminal in Visual Studio Code.
- Check the updated version by running this command:
ollama --version
What should the version show?
The updated command should report v0.40.4 or a newer release.
ⓧ Command not found
Ollama needs its Windows installer before PowerShell can use the command.
- Download OllamaSetup.exe from the official Ollama Windows page.
- Run OllamaSetup.exe to install Ollama for your Windows user.
- Close the current PowerShell terminal after the installer finishes.
- Open a new PowerShell terminal in Visual Studio Code.
- Confirm the installation by running this command:
ollama --version
What should the installation show?
PowerShell should now print an Ollama version instead of reporting a missing command.
Still missing the Ollama command?
Restart Visual Studio Code so its terminal loads the updated command path. Confirm that the Ollama application is also visible in the Windows system tray.
Still stuck? Help me make Ollama available in Windows PowerShell
The first model run downloads qwen3:4b before opening an interactive prompt. Download progress stays visible in PowerShell while Ollama prepares the model.
- Download and start the local model by running this command:
ollama run qwen3:4b
What does this command do?
Ollama downloads qwen3:4b when the model is missing. It then opens an interactive local chat backed by that model.
Ollama serves local requests at http://127.0.0.1:11434. Langflow connects to this address in the next step.
- Test the running model by entering this prompt:
Reply with one sentence confirming you are running locally.
What does this test prove?
A coherent response proves that Ollama can load the model and generate text locally. It removes the model setup as a possible cause of errors in the Langflow flow.
You should see a short response generated by qwen3:4b. That is the local reasoning layer working before any inventory data is connected.
- Close the interactive model terminal after the response appears.
- Return to the running Langflow installation.
- Search the component sidebar for Agent.
- Search the component sidebar for Ollama.
- Search the component sidebar for SQL Database.
- Search the component sidebar for Chat Input.
- Search the component sidebar for Chat Output.
All five components should appear in the component sidebar. This confirms that the running Langflow installation can support the flow built in the next step.
Is a Langflow component missing?
Clear the component search between names so the previous filter does not hide the next result. Confirm that you are searching inside the flow editor component sidebar.
Still stuck? Help me find the required components in Langflow
Build the inventory import script
The source CSV remains the system of record. The importer reads that export and creates a disposable database containing an inventory table plus an inventory_schema reference table.
Why use a SQLite snapshot?
A structured snapshot supports exact counts, filters, sums, and averages. The chatbot can query the copy without changing the source CSV.
Re-exporting the CSV lets you rebuild the database whenever the inventory changes.
- Create import_inventory.py inside the Streamlit project folder.
- Add the imports plus the identifier normalizer by copying this code into import_inventory.py:
import csv
import re
import sqlite3
import sys
from pathlib import Path
def normalize_identifier(value: str, position: int) -> str:
normalized = re.sub(r"[^0-9A-Za-z_]+", "_", value.strip()).strip("_").lower()
if not normalized:
normalized = f"column_{position}"
if normalized[0].isdigit():
normalized = f"column_{normalized}"
return normalized
What does this code do?
- The imports provide CSV reading, text normalization, SQLite access, command arguments, and filesystem paths.
- The normalize_identifier() function converts spreadsheet headings into lowercase SQL column names.
- Fallback names preserve blank headings and headings that begin with numbers.
- Save import_inventory.py.
- Confirm that normalize_identifier appears in the editor symbol list.
Seeing an error in the normalizer?
Check the indentation beneath each if statement. Confirm that the regular expression and replacement strings retain their quotation marks.
Still stuck? Help me correct normalize_identifier in my importer
- Add unique column-name handling below normalize_identifier() by copying this function:
def unique_headers(raw_headers: list[str]) -> list[str]:
counts: dict[str, int] = {}
headers: list[str] = []
for position, raw_header in enumerate(raw_headers, start=1):
base = normalize_identifier(raw_header, position)
counts[base] = counts.get(base, 0) + 1
suffix = counts[base]
headers.append(base if suffix == 1 else f"{base}_{suffix}")
return headers
How are duplicate headings handled?
The unique_headers() function counts repeated normalized names. Later duplicates receive numeric suffixes so every SQLite column remains unique.
- Save import_inventory.py.
- Confirm that unique_headers appears beneath normalize_identifier in the editor symbol list.
Are duplicate headers still colliding?
Confirm that counts[base] increases before the suffix is assigned. Check that the final line returns headers.
Still stuck? Help me debug unique_headers in my CSV importer
- Add SQL type inference below unique_headers() by copying this function:
def infer_sql_type(values: list[str]) -> str:
non_blank = [value.strip() for value in values if value.strip()]
if not non_blank:
return "TEXT"
try:
for value in non_blank:
int(value)
return "INTEGER"
except ValueError:
pass
try:
for value in non_blank:
float(value)
return "REAL"
except ValueError:
return "TEXT"
How are SQL types selected?
- Blank columns become TEXT so they still have a valid database type.
- Columns containing whole numbers become INTEGER.
- Columns containing decimal numbers become REAL.
- Every remaining column becomes TEXT.
- Save import_inventory.py.
- Confirm that the editor shows no red error marker beside infer_sql_type.
Is type inference marked as invalid?
Check that both try blocks have matching except ValueError blocks. Keep the second return "TEXT" inside its exception block.
Still stuck? Help me fix infer_sql_type in my inventory importer
- Add value conversion below infer_sql_type() by copying this function:
def convert_value(value: str, sql_type: str):
cleaned = value.strip()
if cleaned == "":
return None
if sql_type == "INTEGER":
return int(cleaned)
if sql_type == "REAL":
return float(cleaned)
return cleaned
Why convert each CSV value?
CSV readers return text. The convert_value() function converts numeric cells before insertion and turns blank cells into database null values.
- Save import_inventory.py.
- Confirm that the editor symbol list now shows all four helper functions.
Are numeric values staying as text?
Confirm that the comparisons use uppercase INTEGER and REAL values. Check that each conversion returns from the matching condition.
Still stuck? Help me debug convert_value in my inventory importer
The main importer validates the CSV before creating any database tables. This catches missing files and empty exports before they can produce a misleading snapshot.
- Add the opening section of import_inventory() below convert_value() by copying this code:
def import_inventory(csv_path: Path, database_path: Path) -> None:
if not csv_path.is_file():
raise FileNotFoundError(f"CSV file not found: {csv_path}")
with csv_path.open("r", encoding="utf-8-sig", newline="") as csv_file:
raw_rows = list(csv.reader(csv_file))
if len(raw_rows) < 2:
raise ValueError("The CSV must contain a header row and at least one data row.")
raw_headers = raw_rows[0]
if not raw_headers:
raise ValueError("The CSV header row is empty.")
What gets validated first?
- The file check stops the import when the requested CSV path does not exist.
- The CSV reader preserves quoted cells and handles a UTF-8 byte-order marker.
- The row checks require both a header and at least one data row.
- Save import_inventory.py.
- Confirm that import_inventory appears beneath the helper functions in the editor symbol list.
Is the CSV validation block misaligned?
Keep every validation line indented inside import_inventory(). Keep the raw_headers assignment outside the earlier row-count condition.
Still stuck? Help me correct the opening of import_inventory
- Add row normalization directly below the empty-header check by copying this code:
width = len(raw_headers)
rows: list[list[str]] = []
for raw_row in raw_rows[1:]:
normalized_row = raw_row[:width] + [""] * max(0, width - len(raw_row))
if any(cell.strip() for cell in normalized_row):
rows.append(normalized_row)
if not rows:
raise ValueError("The CSV does not contain any non-empty data rows.")
headers = unique_headers(raw_headers)
column_values = [list(column) for column in zip(*rows)]
sql_types = [infer_sql_type(values) for values in column_values]
How are uneven CSV rows handled?
Each row is trimmed to the header width or padded with blank cells. Completely empty rows are excluded from the snapshot.
The remaining lines normalize the headings and infer one SQL type for each column.
- Save import_inventory.py.
- Confirm that the newly added lines remain indented inside import_inventory().
Are rows missing after normalization?
Check that the loop starts at raw_rows[1:] so it excludes only the header row. Confirm that rows.append(normalized_row) sits inside the non-empty row condition.
Still stuck? Help me debug CSV row normalization
- Add the database connection skeleton below the type inference lines by copying this code:
database_path.parent.mkdir(parents=True, exist_ok=True)
connection = sqlite3.connect(database_path)
try:
connection.execute('DROP TABLE IF EXISTS "inventory"')
connection.execute('DROP TABLE IF EXISTS "inventory_schema"')
finally:
connection.close()
Why recreate the snapshot tables?
The database folder is created when needed. Existing snapshot tables are dropped so each import reflects the latest CSV export.
The finally block closes the database connection even when an import error occurs.
- Save import_inventory.py.
- Confirm that the try block has a matching finally block.
Is the database connection left open?
Keep connection.close() indented beneath finally. Keep the table operations inside try.
Still stuck? Help me correct the SQLite connection structure
- Insert the table-creation logic after the second DROP TABLE line and before finally by copying this code:
column_sql = ", ".join(
f'"{header}" {sql_type}'
for header, sql_type in zip(headers, sql_types)
)
connection.execute(f'CREATE TABLE "inventory" ({column_sql})')
connection.execute(
'CREATE TABLE "inventory_schema" '
'("column_name" TEXT, "sql_type" TEXT)'
)
What tables does this create?
The inventory table mirrors the normalized CSV columns. The inventory_schema table records each column name plus its inferred SQL type.
- Save import_inventory.py.
- Confirm that the new table-creation lines remain inside the try block.
Are the CREATE TABLE lines outside try?
Align column_sql with the two earlier connection.execute lines. Keep the generator expression one indentation level deeper.
Still stuck? Help me fix the SQLite table creation block
- Insert the row-conversion logic after the inventory_schema table creation and before finally by copying this code:
quoted_headers = ", ".join(f'"{header}"' for header in headers)
placeholders = ", ".join("?" for _ in headers)
insert_sql = (
f'INSERT INTO "inventory" ({quoted_headers}) '
f'VALUES ({placeholders})'
)
converted_rows = [
tuple(
convert_value(value, sql_type)
for value, sql_type in zip(row, sql_types)
)
for row in rows
]
How is each database row prepared?
The importer quotes every normalized header and creates one parameter placeholder per column. It then converts each CSV value using the inferred type.
Parameter placeholders keep cell values separate from the SQL statement.
- Save import_inventory.py.
- Confirm that the closing bracket for converted_rows aligns with its assignment.
Is converted_rows incomplete?
Check the closing parentheses around zip(row, sql_types) and tuple(). Keep the final closing bracket aligned with converted_rows.
Still stuck? Help me correct the converted_rows comprehension
- Insert the database write operations below converted_rows and before finally by copying this code:
connection.executemany(insert_sql, converted_rows)
connection.executemany(
'INSERT INTO "inventory_schema" '
'("column_name", "sql_type") VALUES (?, ?)',
list(zip(headers, sql_types)),
)
connection.commit()
row_count = connection.execute(
'SELECT COUNT(*) FROM "inventory"'
).fetchone()[0]
What gets written to SQLite?
The first operation inserts every converted inventory row. The second stores the normalized schema reference.
After committing both tables, the final query reads the exact imported row count.
- Save import_inventory.py.
- Confirm that row_count remains inside the try block.
Is the row count unavailable later?
Keep the row_count assignment after connection.commit(). Confirm that the assignment finishes before the finally block begins.
Still stuck? Help me correct the SQLite write and row-count block
- Add the import summary after connection.close() by copying this code:
database_url = f"sqlite:///{database_path.resolve()}"
print(f"Imported rows: {row_count}")
print("Schema:")
for header, sql_type in zip(headers, sql_types):
print(f" {header}: {sql_type}")
print(f"Langflow Database URL: {database_url}")
Why print an import summary?
The summary gives you three checks in one place. You see the imported row count, the normalized schema, and the absolute SQLite Database URL for Langflow.
- Save import_inventory.py.
- Confirm that the summary lines align with the opening of import_inventory().
Is the summary inside finally?
Move database_url back one indentation level from connection.close(). The summary belongs inside import_inventory() but outside finally.
Still stuck? Help me place the inventory import summary correctly
- Add the command-line entry point below import_inventory() by copying this code:
def main() -> None:
if len(sys.argv) != 3:
raise SystemExit(
"Usage: python import_inventory.py <inventory.csv> <inventory_chat.db>"
)
import_inventory(Path(sys.argv[1]), Path(sys.argv[2]))
if __name__ == "__main__":
main()
How does the script receive file paths?
The main() function requires one CSV path plus one database path. It converts both command arguments into Path objects before starting the import.
The final condition runs main() only when you execute this file as a script.
- Save import_inventory.py.
- Confirm that the editor symbol list shows main after import_inventory.
Does the script skip main?
Check that __name__ and __main__ each have two underscore characters on both sides. Keep main() indented beneath the final condition.
Still stuck? Help me correct the Python command-line entry point
Use the tabs below to compare every project artifact before importing the CSV.
✔️ Awesome, I've got everything!
Great. Save every file before moving the inventory export into the project folder.
ⓧ I'd like to double check the full code
Compare each file with the matching reference below.
streamlit==1.66.0
requests==2.34.2
Check requirements.txt
Confirm that both dependencies use the exact pinned versions shown here.
.streamlit/secrets.toml
*.db
__pycache__/
Check .gitignore
Confirm that the secrets file, database files, and Python cache folder each have one exclusion line.
[langflow]
server_url = "http://localhost:7860"
flow_id = "your-flow-id-here"
api_key = "your-langflow-api-key-here"
Check the example secrets
Confirm that the file contains the Langflow section plus all three plain-text placeholders.
import csv
import re
import sqlite3
import sys
from pathlib import Path
def normalize_identifier(value: str, position: int) -> str:
normalized = re.sub(r"[^0-9A-Za-z_]+", "_", value.strip()).strip("_").lower()
if not normalized:
normalized = f"column_{position}"
if normalized[0].isdigit():
normalized = f"column_{normalized}"
return normalized
def unique_headers(raw_headers: list[str]) -> list[str]:
counts: dict[str, int] = {}
headers: list[str] = []
for position, raw_header in enumerate(raw_headers, start=1):
base = normalize_identifier(raw_header, position)
counts[base] = counts.get(base, 0) + 1
suffix = counts[base]
headers.append(base if suffix == 1 else f"{base}_{suffix}")
return headers
def infer_sql_type(values: list[str]) -> str:
non_blank = [value.strip() for value in values if value.strip()]
if not non_blank:
return "TEXT"
try:
for value in non_blank:
int(value)
return "INTEGER"
except ValueError:
pass
try:
for value in non_blank:
float(value)
return "REAL"
except ValueError:
return "TEXT"
def convert_value(value: str, sql_type: str):
cleaned = value.strip()
if cleaned == "":
return None
if sql_type == "INTEGER":
return int(cleaned)
if sql_type == "REAL":
return float(cleaned)
return cleaned
def import_inventory(csv_path: Path, database_path: Path) -> None:
if not csv_path.is_file():
raise FileNotFoundError(f"CSV file not found: {csv_path}")
with csv_path.open("r", encoding="utf-8-sig", newline="") as csv_file:
raw_rows = list(csv.reader(csv_file))
if len(raw_rows) < 2:
raise ValueError("The CSV must contain a header row and at least one data row.")
raw_headers = raw_rows[0]
if not raw_headers:
raise ValueError("The CSV header row is empty.")
width = len(raw_headers)
rows: list[list[str]] = []
for raw_row in raw_rows[1:]:
normalized_row = raw_row[:width] + [""] * max(0, width - len(raw_row))
if any(cell.strip() for cell in normalized_row):
rows.append(normalized_row)
if not rows:
raise ValueError("The CSV does not contain any non-empty data rows.")
headers = unique_headers(raw_headers)
column_values = [list(column) for column in zip(*rows)]
sql_types = [infer_sql_type(values) for values in column_values]
database_path.parent.mkdir(parents=True, exist_ok=True)
connection = sqlite3.connect(database_path)
try:
connection.execute('DROP TABLE IF EXISTS "inventory"')
connection.execute('DROP TABLE IF EXISTS "inventory_schema"')
column_sql = ", ".join(
f'"{header}" {sql_type}'
for header, sql_type in zip(headers, sql_types)
)
connection.execute(f'CREATE TABLE "inventory" ({column_sql})')
connection.execute(
'CREATE TABLE "inventory_schema" '
'("column_name" TEXT, "sql_type" TEXT)'
)
quoted_headers = ", ".join(f'"{header}"' for header in headers)
placeholders = ", ".join("?" for _ in headers)
insert_sql = (
f'INSERT INTO "inventory" ({quoted_headers}) '
f'VALUES ({placeholders})'
)
converted_rows = [
tuple(
convert_value(value, sql_type)
for value, sql_type in zip(row, sql_types)
)
for row in rows
]
connection.executemany(insert_sql, converted_rows)
connection.executemany(
'INSERT INTO "inventory_schema" '
'("column_name", "sql_type") VALUES (?, ?)',
list(zip(headers, sql_types)),
)
connection.commit()
row_count = connection.execute(
'SELECT COUNT(*) FROM "inventory"'
).fetchone()[0]
finally:
connection.close()
database_url = f"sqlite:///{database_path.resolve()}"
print(f"Imported rows: {row_count}")
print("Schema:")
for header, sql_type in zip(headers, sql_types):
print(f" {header}: {sql_type}")
print(f"Langflow Database URL: {database_url}")
def main() -> None:
if len(sys.argv) != 3:
raise SystemExit(
"Usage: python import_inventory.py <inventory.csv> <inventory_chat.db>"
)
import_inventory(Path(sys.argv[1]), Path(sys.argv[2]))
if __name__ == "__main__":
main()
Check import_inventory.py
Confirm that the helper functions, database import, printed summary, and command-line entry point match this complete file.
The importer is ready for a real inventory export. Keep the source file unchanged so the database remains a disposable read-only snapshot.
- Export the existing inventory workbook or dataset as a CSV file.
- Name the exported file inventory.csv.
- Place inventory.csv beside import_inventory.py in the Streamlit project folder.
- Open a fresh PowerShell terminal in Visual Studio Code.
Before you run the importer, what row count and normalized column names do you expect it to report?
- Create the SQLite snapshot by running this command:
python .\import_inventory.py .\inventory.csv .\inventory_chat.db
What does this command create?
The command reads inventory.csv and writes inventory_chat.db beside it. The source CSV is only read.
The database contains inventory for business data plus inventory_schema for normalized column metadata.
You should see an imported row count first. The normalized schema appears next, followed by an absolute sqlite:/// Database URL.
That is the data foundation complete. Your CSV is unchanged, while the chatbot now has a rebuildable snapshot waiting for its SQL tool.
- Compare the printed row count with the number of data rows in inventory.csv.
- Record the printed Database URL here: sqlite:///C:/path/to/inventory_chat.db.
Did the inventory import fail?
- Confirm that inventory.csv sits beside import_inventory.py when the terminal reports that the CSV file is missing.
- Confirm that the CSV contains one header row plus at least one non-empty data row.
- Close any application holding inventory_chat.db open before rerunning the import.
Still stuck? Help me debug my inventory CSV import
Your local model and inventory snapshot are ready. Next, you will build a conversational Langflow shell and see exactly what it cannot know before the SQL tool is connected.
Build the Ungrounded Chat Flow
Your CSV is now a queryable SQLite snapshot. The inventory data is ready for exact questions.
In this step, you will assemble a conversational flow in Langflow powered by Ollama. You will prove that it can chat before testing whether its inventory answer carries evidence.
In this step, get ready to:
- Create a Langflow flow with the four conversation components.
- Connect the local model to an Agent.
- Test a greeting before revealing the missing inventory evidence.
Create the flow shell
A blank flow gives each part of the conversation its own place on the canvas. The first four components create the path from a question to a visible answer.
- Switch back to the running Langflow installation from earlier.
- Create a new flow by selecting Blank Flow.
- Rename the flow to Local Inventory Assistant.
You should see an empty canvas named Local Inventory Assistant.
- Add a Chat Input component to the canvas.
- Add an Agent component to the canvas.
The canvas now shows the entry point for a message beside the component that prepares a response.
- Add an Ollama component to the canvas.
- Add a Chat Output component to the canvas.
You should now see all four components on the canvas.
How the chat path works
- Chat Input accepts the message sent from Playground.
- Ollama supplies the local model through its LanguageModel output.
- Agent uses the connected model to generate a response.
- Chat Output displays the response in Playground.
Connect the local model
The four components need a complete path before a message can travel through the flow. The Ollama component also needs the address of the local model server.
- Set the Ollama Base URL field to http://127.0.0.1:11434.
- Refresh the model list from the Ollama component.
You should see qwen3:4b available in the refreshed model list.
- Select qwen3:4b as the Ollama model.
- Connect the Ollama LanguageModel output to the Agent Language Model port.
You should see a connector line carrying the local model into the Agent.
- Connect Chat Input to the Agent input.
- Connect the Agent Response output to Chat Output.
The canvas should now show one continuous path from Chat Input through the Agent to Chat Output. Ollama should feed the Agent through its separate model connection.
- Open Playground.
- Send Hello as your first message.
That first reply is your early win because the local flow has completed its first round trip through Ollama.
No reply in Playground?
- Confirm that Ollama is still running qwen3:4b locally.
- Check that the Base URL field contains http://127.0.0.1:11434.
- Trace each canvas connection to its stated input port.
Still stuck? Help me troubleshoot my Langflow Agent connection to local Ollama.
Test the inventory question
A greeting checks the conversational shell. An inventory count is a stronger test because the answer must match the snapshot you imported.
Before you send this question, do you think the flow can prove an exact count from your inventory?
- Return to Playground if you switched back to the canvas.
- Send How many rows are in my inventory?.
You will see a conversational reply to the inventory question. Its wording can vary because the model generates the response locally.
- Inspect the Playground trace for a tool action.
The Playground trace contains no RUN_SQL_QUERY action. The reply has no database evidence behind its answer.
Why this result matters
A grounded response ties its claims to a trusted data source. The current Agent only has the local model.
Any inventory count in this reply is unsupported by inventory_chat.db. This intended shortfall shows why exact business questions need a data tool.
You proved that the current flow can converse without database evidence. Next, you will connect the SQL Database component so the Agent can verify inventory facts.
Ground the Agent with SQL
Your Langflow flow can now hold a conversation. Its row-count answer still has no evidence from inventory_chat.db.
The SQL Database component gives the Agent a controlled path into the disposable SQLite snapshot. You will wire that tool before testing grounded answers against the source data.
In this step, get ready to:
- Connect the SQL Database tool to the Agent.
- Constrain the Agent with read-only inventory instructions.
- Verify grounded answers against the source file.
Connect the SQL Database tool
The Database URL printed in Step 1 identifies the local snapshot that Langflow can query. The SQL Database component exposes that snapshot as an Agent tool.
Why use the SQL Database tool?
The tool lets the Agent request exact inventory records. It also handles filters and calculations inside the database.
The source inventory.csv stays untouched. The generated database remains a disposable query layer.
- Return to the Local Inventory Assistant flow in Langflow.
- Find SQL Database with the component search in the left sidebar.
- Drag SQL Database onto the flow canvas.
You will see the SQL Database component appear beside the existing Agent flow.
- Record the sqlite:/// Database URL from Step 1 here: your inventory database URL.
- Paste your inventory database URL into the Database URL field.
- Clear the SQL Query field so it is empty.
The component now points at inventory_chat.db without running a fixed query.
- Enable Tool Mode on the SQL Database component.
You will see the Toolset output become available on the component.
- Connect the Toolset output to the Agent Tools input.
You will see a connection line between the SQL Database component and the Agent.
Tool connection not appearing?
- Confirm that Tool Mode is enabled on the SQL Database component.
- Reconnect from the Toolset output to the Agent Tools input.
- Check that the Database URL ends with inventory_chat.db.
Still stuck? Help me connect the Langflow SQL Database component to my Agent.
Add read-only Agent instructions
A connected tool gives the Agent access to the snapshot. Clear instructions tell it when to query the database and which operations are allowed.
- Select the Agent component on the flow canvas.
The Agent configuration panel displays the settings that control its behaviour.
- Find the Agent Instructions field.
- Replace its contents with You are a read-only inventory assistant. For every inventory question, use RUN_SQL_QUERY before answering. Business data is in the inventory table. Column names and SQL types are listed in the inventory_schema table. Query inventory_schema first when the required columns are unknown. Only run SELECT statements. Never run INSERT, UPDATE, DELETE, DROP, ALTER, CREATE, or REPLACE. Perform filters, counts, sums, averages, and price-by-quantity calculations in SQL. Report exact values returned by the tool. If the requested field or item is missing, say that the inventory snapshot does not contain it. Do not invent values.
The Agent now has a required tool path for every inventory question. It also has explicit limits that keep its requests focused on reading the snapshot.
How do these instructions ground answers?
- The inventory_schema table tells the Agent which normalized columns exist.
- The SELECT rule guides the Agent toward read-only queries.
- The calculation rule keeps counts and totals inside SQL.
- The exact-value rule ties the final response to tool results.
Instructions missing after pasting?
- Confirm that the text appears in the Agent Instructions field.
- Check that the final sentence says Do not invent values.
- Paste the complete instructions again if the ending is missing.
Need another pair of eyes? Help me verify my Langflow Agent instructions.
Verify grounded inventory answers
A confident answer is only useful when you can trace it back to the database. Playground shows both the response and the tool action that produced it.
- Return to Playground for the Local Inventory Assistant flow.
Before you send the row-count question, do you expect the trace to stay empty or show a tool action?
- Enter How many rows are in the inventory table? into the chat input.
You will see a count that matches the Imported rows value from Step 1. The Playground trace will show the RUN_SQL_QUERY action.
- Inspect the Playground trace for the RUN_SQL_QUERY action.
You now have proof that the row count came from inventory_chat.db.
- Enter What column names are available in the inventory? into the chat input.
You will see column names returned from the inventory_schema table.
- Compare the returned names with the normalized schema printed in Step 1.
- Switch back to the inventory.csv tab in Visual Studio Code.
- Identify one real item or category value from the source file.
- Record that value here: your real inventory item or category.
- Return to Langflow Playground.
Before you test this real value, do you expect the Agent to answer from memory or call its new tool?
- Ask one exact inventory question about your real inventory item or category using one returned column.
You will see an answer based on the matching inventory record. The trace will show another RUN_SQL_QUERY action.
- Compare the answer with inventory.csv.
- Inspect the Playground trace for the SQL tool action.
Answer not matching the CSV?
- Confirm that the SQL Database component still uses the Database URL for inventory_chat.db.
- Confirm that the SQL Database Toolset output remains connected to the Agent Tools input.
- Use a normalized column name returned from inventory_schema in your question.
Still seeing the wrong result? Help me troubleshoot a grounded Langflow inventory answer.
That is the grounding gap closed. Your Agent now consults the SQLite snapshot before answering inventory questions.
Next, you will bring this verified workflow into the existing Streamlit app.
Add the Streamlit Chat Page
Your grounded Langflow flow already returns exact inventory answers. The remaining gap is access from the existing Streamlit interface.
This step connects the app to Langflow through its API. It also preserves each browser conversation with session state.
In this step, get ready to:
- Create the Langflow credentials for the app.
- Build the Inventory Chat page.
- Verify grounded answers with a resettable conversation.
Create the Langflow credentials
The chat page needs an API key to authenticate its requests. It also needs the Flow ID to target Local Inventory Assistant.
This part handles a live credential. Keep the key in the local secrets file so it stays outside your committed project files.
- Switch back to the running Langflow interface.
- Click the user icon.
- Select Settings.
- Open Langflow API Keys.
- Click Add New.
- Click Create API Key.
- Copy the new key into a secure password manager.
You now have the credential that lets your local app call Langflow. Keep the key private while you collect the flow identifier.
- Return to the Local Inventory Assistant flow.
- Click Share.
- Click API access.
- Select v1.
- Copy the Flow ID.
- Record the copied Flow ID here: your Langflow flow ID.
Streamlit reads private configuration from .streamlit/secrets.toml. Your existing .gitignore already excludes this file.
- Switch back to Visual Studio Code.
- Use the Explorer sidebar to create .streamlit/secrets.toml inside the existing .streamlit folder.
- Copy this template from .streamlit/secrets.toml.example into the new file:
[langflow]
server_url = "http://localhost:7860"
flow_id = "your-flow-id-here"
api_key = "your-langflow-api-key-here"
How does the secrets file work?
- The langflow section groups the three connection values used by the chat page.
- The server_url value points to the local Langflow server.
- The flow_id value selects your inventory flow.
- The api_key value authenticates each request without placing the key in Python code.
- Keep http://localhost:7860 if Langflow uses its default local address.
- Replace your-flow-id-here with your Langflow flow ID.
- Replace your-langflow-api-key-here with the key stored in your password manager.
- Save .streamlit/secrets.toml.
Having trouble with the credentials?
- Check that the section name remains langflow.
- Keep each value inside its existing quotation marks.
- Confirm that .streamlit/secrets.toml remains listed in .gitignore.
Still stuck? Help me check my Streamlit secrets file without asking me to share the API key.
Build the Langflow API client
The page needs one helper to interpret Langflow responses. A second helper assembles the authenticated request for the selected flow.
- Locate the pages folder in the Explorer sidebar.
- Create the pages folder inside the existing Streamlit project if it is missing.
- Create 2_Inventory_Chat.py inside the pages folder.
- Add the imports plus the response extraction helper by copying this code:
import uuid
import requests
import streamlit as st
def extract_message(payload: dict) -> str:
try:
return payload["outputs"][0]["outputs"][0]["outputs"]["message"]["message"]
except (KeyError, IndexError, TypeError):
try:
return payload["outputs"][0]["outputs"][0]["results"]["message"]["text"]
except (KeyError, IndexError, TypeError) as error:
raise RuntimeError(
"Langflow returned an unexpected response structure."
) from error
What does this code do?
- The imports provide unique session identifiers plus HTTP requests plus Streamlit interface controls.
- The extract_message() helper reads the Chat Output text from the Langflow response.
- The fallback supports the alternate documented result structure.
- The RuntimeError exposes an unexpected response shape as a readable app error.
- Save pages/2_Inventory_Chat.py.
- Check the editor diagnostics for this file. You should see no syntax warning on extract_message().
Seeing an editor error?
- Confirm that extract_message() starts at the left edge of the file.
- Check that each nested dictionary key keeps its quotation marks.
- Compare the indentation inside both try blocks.
Need another pair of eyes? Help me find the syntax issue in my extract_message function.
The request helper reads the private configuration at runtime. It sends the prompt with a browser-specific session identifier.
- Add the API request helper below extract_message() by copying this code:
def ask_inventory_agent(prompt: str) -> str:
config = st.secrets["langflow"]
server_url = str(config["server_url"]).rstrip("/")
flow_id = str(config["flow_id"])
api_key = str(config["api_key"])
endpoint = f"{server_url}/api/v1/run/{flow_id}"
headers = {
"Content-Type": "application/json",
"x-api-key": api_key,
}
body = {
"input_value": prompt,
"input_type": "chat",
"output_type": "chat",
"session_id": st.session_state.session_id,
}
response = requests.post(
endpoint,
headers=headers,
json=body,
timeout=120,
)
response.raise_for_status()
return extract_message(response.json())
How does the request reach Langflow?
- The helper reads the connection values through st.secrets.
- The endpoint targets the /api/v1/run/{flow_id} route for your flow.
- The headers carry the JSON content type plus the API key.
- The body sends the prompt plus chat input and output types plus the current session_id.
- Requests checks the HTTP result before the helper extracts the answer.
- Save pages/2_Inventory_Chat.py.
- Return to the terminal used for the existing Streamlit app.
- Restart the app with its established start command if the process is not running.
- Refresh the app in your browser.
You should see Inventory Chat in the sidebar when the app uses automatic pages discovery. The page is blank until you add its interface in the next substep.
Inventory Chat missing from the sidebar?
- Confirm that the file path is exactly pages/2_Inventory_Chat.py.
- Register pages/2_Inventory_Chat.py with the existing st.navigation router if your app uses one.
- Restart the Streamlit app after updating the router.
Still missing? Help me diagnose why my Streamlit page is not appearing in navigation.
Render and test the chat experience
The interface stores visible messages in session state. A separate session identifier keeps Langflow memory aligned with the current browser conversation.
- Add the page heading plus session setup plus conversation controls below ask_inventory_agent() by copying this code:
if "messages" not in st.session_state:
st.session_state.messages = []
if "session_id" not in st.session_state:
st.session_state.session_id = str(uuid.uuid4())
st.write("# Inventory AI Chatbot")
st.write("Ask about stock, SKUs, prices, categories, and inventory totals.")
if st.button("Clear conversation", type="secondary"):
st.session_state.messages = []
st.session_state.session_id = str(uuid.uuid4())
st.rerun()
for message in st.session_state.messages:
with st.chat_message(message["role"]):
st.write(message["content"])
How does the page remember a conversation?
- The messages list holds the visible user and assistant messages across reruns.
- The session_id value groups Langflow memory for the current browser conversation.
- The Clear conversation button replaces both values.
- The message loop redraws the saved history after each Streamlit rerun.
- Save pages/2_Inventory_Chat.py.
- Refresh Inventory Chat in your browser.
You should see the Inventory AI Chatbot heading plus the inventory prompt description plus the Clear conversation button.
Page controls not appearing?
- Confirm that the new code sits below the complete ask_inventory_agent() function.
- Check that both top-level if statements start at the left edge.
- Refresh the browser after saving the file.
Need help? Help me debug why my Streamlit chat controls are not rendering.
The final block accepts a prompt from the browser. It displays both sides of the exchange while preserving them for the next rerun.
- Add the prompt handling block below the existing message loop by copying this code:
if prompt := st.chat_input("Ask an inventory question"):
st.session_state.messages.append({"role": "user", "content": prompt})
with st.chat_message("user"):
st.write(prompt)
with st.chat_message("assistant"):
try:
answer = ask_inventory_agent(prompt)
except (requests.RequestException, KeyError, RuntimeError, ValueError) as error:
answer = f"The inventory assistant could not complete the request: {error}"
st.write(answer)
st.session_state.messages.append(
{"role": "assistant", "content": answer}
)
What completes the chat loop?
- The chat input stores the submitted question in prompt.
- The first append preserves the user message before the API request starts.
- The assistant block calls ask_inventory_agent() and shows the returned answer.
- The exception handler keeps connection failures visible inside the page.
- The final append preserves the assistant answer for the next rerun.
- Save pages/2_Inventory_Chat.py.
✔️ Awesome, I've got everything!
Your chat page now contains the Langflow client plus session state plus the complete chat interface.
ⓧ I'd like to double check the full code
import uuid
import requests
import streamlit as st
def extract_message(payload: dict) -> str:
try:
return payload["outputs"][0]["outputs"][0]["outputs"]["message"]["message"]
except (KeyError, IndexError, TypeError):
try:
return payload["outputs"][0]["outputs"][0]["results"]["message"]["text"]
except (KeyError, IndexError, TypeError) as error:
raise RuntimeError(
"Langflow returned an unexpected response structure."
) from error
def ask_inventory_agent(prompt: str) -> str:
config = st.secrets["langflow"]
server_url = str(config["server_url"]).rstrip("/")
flow_id = str(config["flow_id"])
api_key = str(config["api_key"])
endpoint = f"{server_url}/api/v1/run/{flow_id}"
headers = {
"Content-Type": "application/json",
"x-api-key": api_key,
}
body = {
"input_value": prompt,
"input_type": "chat",
"output_type": "chat",
"session_id": st.session_state.session_id,
}
response = requests.post(
endpoint,
headers=headers,
json=body,
timeout=120,
)
response.raise_for_status()
return extract_message(response.json())
if "messages" not in st.session_state:
st.session_state.messages = []
if "session_id" not in st.session_state:
st.session_state.session_id = str(uuid.uuid4())
st.write("# Inventory AI Chatbot")
st.write("Ask about stock, SKUs, prices, categories, and inventory totals.")
if st.button("Clear conversation", type="secondary"):
st.session_state.messages = []
st.session_state.session_id = str(uuid.uuid4())
st.rerun()
for message in st.session_state.messages:
with st.chat_message(message["role"]):
st.write(message["content"])
if prompt := st.chat_input("Ask an inventory question"):
st.session_state.messages.append({"role": "user", "content": prompt})
with st.chat_message("user"):
st.write(prompt)
with st.chat_message("assistant"):
try:
answer = ask_inventory_agent(prompt)
except (requests.RequestException, KeyError, RuntimeError, ValueError) as error:
answer = f"The inventory assistant could not complete the request: {error}"
st.write(answer)
st.session_state.messages.append(
{"role": "assistant", "content": answer}
)
Before you test the page, do you expect its row count to match the grounded result from Langflow Playground?
- Refresh the running Streamlit app.
- Select Inventory Chat from the sidebar.
- Enter How many rows are in the inventory table? in the chat input.
- Press Enter to submit the question.
You should see the same exact row count returned in Step 1 and Langflow Playground. That match proves the Streamlit page reaches the grounded flow.
- Enter What table did you query for that answer? as a related follow-up.
- Press Enter to submit the follow-up.
- Click Clear conversation.
You should see both conversations disappear from the page. The app also replaces the Langflow session identifier.
- Enter What was my previous question? in the empty conversation.
- Press Enter to submit the question.
The assistant should have no earlier conversation to reference. This confirms that the visible history and Langflow session reset together.
The chat request did not complete?
- Confirm that Langflow remains available at the address saved in server_url.
- Confirm that the Flow ID belongs to Local Inventory Assistant.
- Create a replacement API key if the saved key was copied incorrectly.
- Check that Ollama is still running qwen3:4b locally.
Still blocked? Help me diagnose my Streamlit request without asking me to share my API key.
You have closed the final integration gap. Your existing inventory app can now hold a local conversation backed by exact SQL results.
Secret mission
Reuse the Inventory Flow as an Agent Tool
Your grounded inventory assistant already answers questions with exact database results. Now make that capability reusable by letting a second Langflow agent call the completed flow as a tool.
Clean Up Your Resources
Clean Up Your Resources
All project resources stay local, so they create no ongoing service charges. Decide whether to keep them active, pause the running tools, or remove them to reclaim disk space.
Resources you used:
- A local SQLite snapshot named inventory_chat.db containing the inventory and inventory_schema tables.
- A Streamlit Inventory Chat page at pages\2_Inventory_Chat.py.
- An Ollama installation with downloaded qwen3:4b model data.
- Two Langflow flows consisting of Local Inventory Assistant and the separate second Simple Agent flow.
- A local secrets file at .streamlit\secrets.toml.
- A Langflow API key created for the Inventory Chat integration.
Keep everything running
No action is needed. Choose this if you want to continue asking inventory questions through the existing app.
- Keep inventory_chat.db beside import_inventory.py for grounded inventory queries.
- Retain pages\2_Inventory_Chat.py to keep Inventory Chat available in the app.
- Keep .streamlit\secrets.toml excluded through .gitignore.
- Leave both Langflow flows available for direct use and agent composition.
- Keep Ollama installed with the qwen3:4b model ready for local inference.
Pause - I'll come back to this later
Stop the running processes to free up memory. Your files and flows remain ready for the next session.
- Return to the terminal running the Streamlit app.
- Press Ctrl+C to stop the app.
- Close the running Langflow installation.
- Quit Ollama from the Windows system tray.
- Keep the local database and chatbot files inside the existing Streamlit project.
Delete - I don't want to use this again
Remove the inventory chatbot resources to start fresh.
Deletion is permanent. Your original inventory.csv stays untouched by these steps.
Delete the local chatbot resources:
- In the Visual Studio Code file sidebar, delete inventory_chat.db.
- Delete .streamlit\secrets.toml from the same file sidebar.
- Delete pages\2_Inventory_Chat.py from the same file sidebar.
- Remove the Inventory Chat registration from your existing st.navigation router if you added one.
Delete the Langflow resources:
- Return to the Langflow flow list.
- Use the action menu for Local Inventory Assistant to remove the flow.
- Use the action menu for the separate second Simple Agent flow to remove it.
- Open the user menu in Langflow.
- Select Settings.
- Open Langflow API Keys.
- Remove the API key created for this project.
Once those entries are gone, Langflow no longer holds the project flows or their API credential.
Remove Ollama and its model data:
Check Other Ollama Models First
The %HOMEPATH%\.ollama folder can contain every Ollama model downloaded for your Windows account. Delete it only if you want to remove all local Ollama model data.
- Press the Windows key to open search.
- Enter Add or remove programs into the search field.
- Open the matching Windows setting.
- Uninstall Ollama from its app entry.
- Open File Explorer.
- Enter %HOMEPATH%\.ollama into the address bar.
- Delete the .ollama folder to remove the downloaded model data.
Cleanup complete. Your source inventory.csv remains available for rebuilding a fresh snapshot.
Nice Work!
Nice Work!
You did it! Your local Langflow inventory chatbot now delivers exact SQLite-backed answers inside your Streamlit app using local Ollama inference.
You've learned how to:
- Convert an inventory CSV into a SQLite snapshot with normalized columns plus an inventory_schema reference table. The source file stays unchanged.
- Expose the designed shortfall in an ungrounded chatbot by asking for an exact row count. Ground the Langflow agent with RUN_SQL_QUERY so inventory answers come from read-only SQL queries.
- Connect the Inventory Chat page to the Langflow run API. Preserve conversation history with per-browser session state plus a clear conversation reset.
- Secret Mission: Reuse Local Inventory Assistant as a Run Flow tool inside a second Langflow agent. The same grounded workflow now powers another local agent.
Ready to quiz yourself?