Day 9 of 10
Wire the agent, the RAG pipeline, and the Marimo UI into one cohesive app. Everything you built this week, together.
You have been building pieces. Today they become a product.
The RAG pipeline from Day 6. The Strands agent from Days 7 and 8. The Marimo UI from Day 5. Each piece worked in isolation. Today you wire them together into a single app that is more useful than any of them were alone.
This is the day the course stops being a course and starts being a portfolio.
Your app has two modes and one interface.
Document mode: The user uploads a document. The app indexes it with embeddings, retrieves relevant chunks, and answers from the document’s actual content.
Research mode: The user asks about something in the world. The agent searches Wikipedia and answers with what it actually found.
One Marimo app. Two intelligence modes. The user chooses.
You will build this in two files: app_backend.py holds all the logic, and app.py holds the Marimo UI. Keeping them separate means you can test the backend without touching the UI.
Create app_backend.py in your project directory. You will build it in four sections.
Section 1: Imports and model setup
import os
import json
import numpy as np
import urllib.request
import urllib.parse
from groq import Groq
from sentence_transformers import SentenceTransformer
from strands import Agent, tool
from strands.models.openai import OpenAIModel
groq_client = Groq(api_key=os.environ.get("GROQ_API_KEY"))
embedder = SentenceTransformer("all-MiniLM-L6-v2")
openai_model = OpenAIModel(
client_args={
"api_key": os.environ.get("GROQ_API_KEY"),
"base_url": "https://api.groq.com/openai/v1",
},
model_id="llama-3.3-70b-versatile",
params={"parallel_tool_calls": False, "temperature": 0},
)
Section 2: RAG pipeline
These are the same functions you built on Day 6. Chunk the document, embed each chunk, retrieve the closest ones at query time.
def chunk_text(text, chunk_size=300, overlap=50):
words = text.split()
chunks, i = [], 0
while i < len(words):
chunks.append(" ".join(words[i : i + chunk_size]))
i += chunk_size - overlap
return chunks
def build_index(text):
chunks = chunk_text(text)
embeddings = embedder.encode(chunks)
return list(zip(chunks, embeddings))
def cosine_similarity(a, b):
a, b = np.array(a), np.array(b)
return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))
def retrieve(query, index, top_k=3):
q_vec = embedder.encode(query)
scored = sorted(
[(cosine_similarity(q_vec, vec), chunk) for chunk, vec in index],
reverse=True
)
return [chunk for _, chunk in scored[:top_k]]
def ask_document(question, index, system_prompt):
context = "\n\n---\n\n".join(retrieve(question, index))
response = groq_client.chat.completions.create(
model="llama-3.3-70b-versatile",
messages=[
{"role": "system", "content": system_prompt + "\nAnswer using only the provided context. Say so if the context is insufficient."},
{"role": "user", "content": f"Context:\n{context}\n\nQuestion: {question}"}
]
)
return response.choices[0].message.content
Section 3: Agent tools
@tool
def search_wikipedia(query: str) -> str:
"""Search Wikipedia and return a summary for the given topic or query."""
try:
url = f"https://en.wikipedia.org/api/rest_v1/page/summary/{urllib.parse.quote(query)}"
req = urllib.request.Request(url, headers={"User-Agent": "AppBot/1.0"})
with urllib.request.urlopen(req, timeout=8) as r:
data = json.loads(r.read().decode())
return f"{data.get('title', '')}\n\n{data.get('extract', 'No summary found.')}"
except Exception as e:
return f"Search failed: {e}"
@tool
def get_wikipedia_details(topic: str) -> str:
"""Get a longer, more detailed Wikipedia extract for a specific topic."""
try:
params = urllib.parse.urlencode({
"action": "query",
"prop": "extracts",
"explaintext": 1,
"redirects": 1,
"titles": topic,
"format": "json",
"exsentences": 20,
})
url = f"https://en.wikipedia.org/w/api.php?{params}"
req = urllib.request.Request(url, headers={"User-Agent": "AppBot/1.0"})
with urllib.request.urlopen(req, timeout=8) as r:
data = json.loads(r.read().decode())
pages = data.get("query", {}).get("pages", {})
for page in pages.values():
if page.get("pageid", -1) == -1:
return f"No Wikipedia article found for '{topic}'. Try search_wikipedia instead."
extract = page.get("extract", "")
if extract:
return extract[:3000]
return f"No content found for '{topic}'. Try search_wikipedia instead."
except Exception as e:
return f"Search failed: {e}"
Section 4: Research agent
research_agent = Agent(
model=openai_model,
tools=[search_wikipedia, get_wikipedia_details],
system_prompt="You are a research assistant. You have two tools: search_wikipedia for quick summaries and get_wikipedia_details for in-depth content. Use them to answer questions accurately."
)
That is app_backend.py. Run it once to confirm it imports cleanly:
uv run --env-file .env python app_backend.py
No output means no errors. Good.
Create app.py in the same directory. This is your Marimo notebook — open it with:
uv run --env-file .env marimo edit app.py
Add each cell in order.
Cell 1: Imports
import marimo as mo
from app_backend import build_index, ask_document, research_agent
Cell 2: Mode selector
mode = mo.ui.radio(
options=["Document Assistant", "Research Agent"],
value="Document Assistant",
label="Mode"
)
mode
Cell 3: File upload (only appears in Document mode)
mo.stop(mode.value != "Document Assistant")
upload = mo.ui.file(label="Upload a .txt document", filetypes=[".txt"])
upload
Cell 4: Build the index
This cell always defines doc_index. If a file is uploaded, it builds the index. If not, it returns None. The answer cell will check this before trying to use it.
doc_index = (
build_index(upload.value[0].contents.decode("utf-8"))
if upload.value
else None
)
if doc_index:
mo.callout(mo.md(f"Document indexed. {len(doc_index)} chunks ready."), kind="success")
Cell 5: Question form
The .form() pattern from Day 5. question.value is None until submitted, then holds the text.
system_prompt_input = mo.ui.text_area(
value="You are a sharp, direct assistant. Answer precisely. No filler.",
label="System prompt (Document mode only)",
rows=3
)
question = mo.ui.text(placeholder="Ask anything...", full_width=True).form(
submit_button_label="Ask →"
)
mo.vstack([system_prompt_input, question])
Cell 6: Answer
mo.stop(question.value is None)
if mode.value == "Document Assistant":
mo.stop(doc_index is None, mo.callout(mo.md("Upload a document first."), kind="warn"))
result = ask_document(question.value, doc_index, system_prompt_input.value)
else:
result = str(research_agent(question.value))
mo.md(f"### Answer\n\n{result}")
Upload a document you own. Ask it questions only that document would know. See it answer from your actual text.
Switch to Research mode. Ask something factual. Watch the agent call Wikipedia and synthesize.
Then ask the same question in both modes. One is grounded in your document. One is grounded in Wikipedia. Neither is guessing.
It is a knowledge interface. Personal knowledge from your documents. Public knowledge from the web. One system prompt you control. One UI you can share.
This is not a course project. This is a tool you will keep. Tomorrow you ship it.