One of the most common questions I hear from developer teams: "We want an AI chat tool tuned for our workflow, but handing everyone a generic ChatGPT account feels wrong from a security and customization standpoint." A solid alternative is building your own with Antigravity (Gemini API) and Streamlit. You control the system prompt, the model, and where the data goes.
Streamlit is a Python-first web framework beloved by data scientists and indie developers. No frontend experience required—just Python. In this guide, we'll build a fully functional internal chat tool with persistent conversation history, real-time streaming output, a customizable system prompt, and a clean settings sidebar. By the end, you'll also have a deployment path via Streamlit Community Cloud.
Why Streamlit + Antigravity?
You could build this with React and a FastAPI backend, but that adds setup time, deployment complexity, and—if you're a solo developer or small team—maintenance burden you may not need.
With Streamlit, everything lives in a single Python file. Here are the situations where I reach for this combination:
- Prototypes for internal teammates that need to be running today, not next sprint
- Task-specific bots: code review assistant, document summarizer, onboarding Q&A, support triage
- Rapid validation before committing to a production-grade Next.js or React frontend
The key advantage is speed-to-working-demo. Paired with Antigravity's Gemini 2.5 Pro, the output quality is genuinely useful for real work—not just impressive in a demo. That said, if your requirements include fine-grained access control or an enterprise design system, you'll eventually want to graduate to a proper frontend. Streamlit gets you there with something tangible to show, and that changes the conversation.
Setup
Three packages cover everything you need.
pip install streamlit google-genai python-dotenvStore your API key in a .env file at the project root. Never hard-code it in the script.
GEMINI_API_KEY=YOUR_GEMINI_API_KEY
Add .env to .gitignore before your first commit—this is the step people most often skip and then regret.
echo ".env" >> .gitignoreYour project structure should look like this when you're done:
my-chat-app/
├── app.py ← main application
├── .env ← API key (never commit)
├── .gitignore
└── requirements.txt
A requirements.txt that pins versions will save you from surprise breakage when your teammates clone the repo.
streamlit>=1.35.0
google-genai>=1.5.0
python-dotenv>=1.0.0
Building the Basic Chat UI
Create app.py with this minimal working implementation.
import streamlit as st
from google import genai
from dotenv import load_dotenv
import os
# Load environment variables from .env
load_dotenv()
# Initialize the Antigravity (Gemini) client
client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
# Page configuration
st.set_page_config(page_title="Internal AI Chat", page_icon="🤖", layout="wide")
st.title("🤖 Internal AI Assistant")
# Store chat history in session state.
# Streamlit reruns the entire script on every user interaction,
# so session_state is the only way to persist messages across renders.
if "messages" not in st.session_state:
st.session_state.messages = []
# Render previous messages
for message in st.session_state.messages:
with st.chat_message(message["role"]):
st.markdown(message["content"])
# Chat input — returns None when empty, so the walrus operator keeps things clean
if prompt := st.chat_input("Type your message..."):
st.session_state.messages.append({"role": "user", "content": prompt})
with st.chat_message("user"):
st.markdown(prompt)
# Build conversation history in the format the API expects
with st.chat_message("assistant"):
history = [
{"role": m["role"], "parts": [{"text": m["content"]}]}
for m in st.session_state.messages[:-1]
]
response = client.models.generate_content(
model="gemini-2.5-pro",
contents=history + [{"role": "user", "parts": [{"text": prompt}]}],
)
answer = response.text
st.markdown(answer)
st.session_state.messages.append({"role": "assistant", "content": answer})Launch the app:
streamlit run app.pyA browser tab opens with a working chat interface. Conversation state is preserved across messages. This is already enough to share with a colleague and get real feedback.
Adding Streaming Output
In the basic version, nothing appears on screen until the full response finishes generating. For longer answers that pause is noticeable—and it makes the app feel slower than it actually is. Streaming fixes this by rendering each token as it arrives.
with st.chat_message("assistant"):
answer_placeholder = st.empty()
full_answer = ""
for chunk in client.models.generate_content_stream(
model="gemini-2.5-pro",
contents=history + [{"role": "user", "parts": [{"text": prompt}]}],
):
if chunk.text:
full_answer += chunk.text
# Append a blinking cursor to signal active generation
answer_placeholder.markdown(full_answer + "▌")
# Finalize without the cursor indicator
answer_placeholder.markdown(full_answer)
st.session_state.messages.append({"role": "assistant", "content": full_answer})The ▌ cursor is a small touch, but it meaningfully reduces the sense that the app is frozen. It's one of those details that goes unnoticed when present and gets complained about when missing.
Customizing Behavior with a System Prompt
A system prompt is what turns a general-purpose chatbot into a tool that actually serves your team's workflow. Pass it via the system_instruction parameter in GenerateContentConfig.
SYSTEM_PROMPT = """
You are the internal AI assistant for Acme Engineering.
Rules:
- Always respond in English
- Never reference confidential data or personally identifiable information
- When uncertain, say "I need to verify this" rather than speculating
- For code questions, always include a complete, runnable example
- Keep answers concise unless the user explicitly asks for detail
"""
response = client.models.generate_content(
model="gemini-2.5-pro",
contents=history + [{"role": "user", "parts": [{"text": prompt}]}],
config=genai.types.GenerateContentConfig(
system_instruction=SYSTEM_PROMPT,
temperature=0.3, # lower = more factual, less creative
max_output_tokens=2048,
),
)For internal tooling, I keep temperature between 0.1 and 0.4. The goal is accurate and consistent answers, not surprising ones. For document summarization and FAQ-style Q&A, 0.2 works well. Bump it to 0.5–0.7 only if you want more expressive writing.
One more practical tip: if your team has an internal knowledge base or runbook, you can inject relevant excerpts into the system prompt dynamically. That's where this approach starts to pull significantly ahead of off-the-shelf tools.
Adding a Settings Sidebar
Streamlit's sidebar makes it easy to expose controls without cluttering the main chat area.
with st.sidebar:
st.header("Settings")
# Model selector — Pro for quality, Flash for speed/cost
model_name = st.selectbox(
"Model",
["gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.0-flash"],
help="Pro = highest quality. Flash = faster and cheaper, great for Q&A.",
)
# Expose temperature for power users
temperature = st.slider("Temperature", 0.0, 1.0, 0.3, 0.1)
st.divider()
# Session management
if st.button("🔄 Clear conversation", type="secondary"):
st.session_state.messages = []
st.rerun()
# Show message count
msg_count = len(st.session_state.messages)
if msg_count:
st.caption(f"{msg_count} message{'s' if msg_count \!= 1 else ''} in this session")gemini-2.5-flash is noticeably faster and meaningfully cheaper than Pro. For everyday Q&A it's often indistinguishable in quality. Making the model switchable lets teammates find the right balance for their task without you having to hard-code a choice.
Handling Errors Gracefully
Production-adjacent tools need to handle API failures without crashing. Wrap the API call in a try/except block.
try:
for chunk in client.models.generate_content_stream(
model=model_name,
contents=history + [{"role": "user", "parts": [{"text": prompt}]}],
config=genai.types.GenerateContentConfig(
system_instruction=SYSTEM_PROMPT,
temperature=temperature,
),
):
if chunk.text:
full_answer += chunk.text
answer_placeholder.markdown(full_answer + "▌")
answer_placeholder.markdown(full_answer)
except genai.errors.APIError as e:
# Show a human-readable error instead of a stack trace
st.error(f"API error: {e.message}")
full_answer = f"[Error: {e.message}]"
except Exception as e:
st.error(f"Unexpected error: {str(e)}")
full_answer = "[An unexpected error occurred. Please try again.]"Showing a friendly error message instead of a raw stack trace is the difference between a tool people trust and one they avoid.
Deploying to Streamlit Community Cloud
Streamlit Community Cloud (share.streamlit.io) is the easiest way to share your app with a team. It's free for public repos, connects directly to GitHub, and redeploys automatically on push.
For deployment, use .streamlit/secrets.toml instead of .env.
# .streamlit/secrets.toml — never commit to version control
GEMINI_API_KEY = "YOUR_GEMINI_API_KEY"Update the key-loading logic to handle both environments.
# Use st.secrets in Streamlit Cloud, fall back to os.environ locally
api_key = st.secrets.get("GEMINI_API_KEY") or os.environ.get("GEMINI_API_KEY")
if not api_key:
st.error("GEMINI_API_KEY is not configured.")
st.stop() # halt cleanly rather than throwing a cryptic downstream error
client = genai.Client(api_key=api_key)Push to GitHub, connect the repo in Streamlit Community Cloud's dashboard, and you're live. Paste the URL in Slack and your team can start using it immediately from any browser—no installation required on their end.
What I appreciate most about this approach is the feedback loop it creates. Ship a working demo in an afternoon, get real usage from real teammates, and iterate from there. The decision to invest in a production frontend can wait until you've confirmed the tool is actually solving a problem worth solving.
For a deeper look at the Antigravity Python API itself, the google-genai SDK quickstart guide covers the SDK fundamentals, and the Python SDK production master guide goes deeper on auth, retry logic, and cost management.
The next step is simple: run pip install streamlit google-genai and see how far you get in an hour.