Assemble Your Jarvis 🤖
Nine lessons, six levels, one goal. Now you assemble a genuine assistant from the pieces you built: memory, streaming, tools, your own data, all in a program that is yours to extend forever.
What you are building
A command-line assistant that remembers across sessions, streams its replies, can use tools to actually do things, and is structured so you can add capabilities without rewriting it. It is small enough to read in one sitting and real enough to use every day.
Like the workshop projects, the point is that you build it. What follows is the architecture and the load-bearing pieces, wired together, with every part traceable to a lesson. Type it, run it, then make it yours: your persona, your tools, your data.
The architecture
jarvis/
main.py the chat loop: memory + streaming (Lessons 55, 56)
tools.py the tools Jarvis can use, and the runner (Lesson 57)
memory.py save/load conversation and preferences (Lessons 21, 23, 55)
config.py model, persona, settings; key from .env (Lessons 54, 26)
.env your API key. GITIGNORED. (Lesson 52)
.gitignore
requirements.txt pinned dependencies (Lesson 26)
Separate files with clear jobs (Lesson 20), so each part can be understood, tested and changed on its own. This is the difference between a script and software, which was the whole theme of Level 3.
config.py: settings in one place
"""All the knobs, in one place, so nothing is hard-coded elsewhere."""
from dotenv import load_dotenv
import os
load_dotenv()
API_KEY = os.environ.get("ANTHROPIC_API_KEY")
MODEL = "claude-sonnet-5" # the everyday workhorse (Lesson 54)
MAX_TOKENS = 1024
PERSONA = (
"You are Jarvis, a personal assistant: concise, warm, and honest. "
"You have tools; use them for anything factual, current, or computational "
"rather than guessing. If you do not know something and have no tool for it, "
"say so plainly. Never invent facts, citations, or figures."
)
MEMORY_FILE = "jarvis_state.json"
tools.py: what Jarvis can actually do
"""Jarvis's tools, and the runner that executes them. Start safe (Lesson 57)."""
import ast
import operator
from datetime import date, datetime
def calculate(expression: str) -> str:
"""Exact arithmetic. The model is unreliable at this; the tool is not."""
ops = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
ast.Div: operator.truediv, ast.Pow: operator.pow, ast.USub: operator.neg}
def ev(node):
if isinstance(node, ast.Constant):
return node.value
if isinstance(node, ast.BinOp):
return ops[type(node.op)](ev(node.left), ev(node.right))
if isinstance(node, ast.UnaryOp):
return ops[type(node.op)](ev(node.operand))
raise ValueError("unsupported expression")
return str(ev(ast.parse(expression, mode="eval").body))
def current_datetime() -> str:
"""The date and time, which the model cannot know on its own."""
now = datetime.now()
return now.strftime("%A, %d %B %Y, %H:%M")
# The registry: name -> (function, schema). Add a tool by adding one entry.
TOOLS = {
"calculate": {
"fn": calculate,
"schema": {
"name": "calculate",
"description": "Evaluate an arithmetic expression exactly.",
"input_schema": {
"type": "object",
"properties": {"expression": {"type": "string"}},
"required": ["expression"],
},
},
},
"current_datetime": {
"fn": lambda: current_datetime(),
"schema": {
"name": "current_datetime",
"description": "Get the current date and time.",
"input_schema": {"type": "object", "properties": {}},
},
},
}
def tool_schemas():
"""The list of schemas to send to the model."""
return [t["schema"] for t in TOOLS.values()]
def run_tool(name: str, tool_input: dict) -> str:
"""Execute a requested tool safely. Unknown tools fail loudly, not silently."""
if name not in TOOLS:
return f"Error: no such tool {name!r}"
try:
return TOOLS[name]["fn"](**tool_input)
except Exception as err: # a broken tool must not crash Jarvis
return f"Error running {name}: {err}"
The registry pattern is the important idea: adding a capability means adding one dictionary entry, not editing the chat loop. Want Jarvis to check the weather? Write the function, add one entry, done. This is open-for-extension design, and it is what makes the assistant yours to grow.
memory.py: remembering across sessions
"""Persist the conversation and any learned preferences (Lessons 21, 23, 55)."""
import json
from pathlib import Path
from config import MEMORY_FILE
def load_state():
"""Load saved conversation and preferences, or start fresh."""
file = Path(MEMORY_FILE)
if file.exists():
return json.loads(file.read_text(encoding="utf-8"))
return {"messages": [], "preferences": {}}
def save_state(state):
"""Persist the whole assistant state to disk."""
Path(MEMORY_FILE).write_text(json.dumps(state, indent=2), encoding="utf-8")
def trim(messages, keep_turns=12):
"""Bound the history so cost and context stay under control (Lesson 55)."""
limit = keep_turns * 2
return messages if len(messages) <= limit else messages[-limit:]
main.py: the loop that ties it together
"""Jarvis: memory + streaming + tools, assembled (Lessons 55, 56, 57)."""
import anthropic
import config
import memory
import tools
def get_reply(client, messages):
"""One full turn, running any tools the model asks for, streaming the final answer."""
while True:
# Stream so the wait feels alive (Lesson 56)
with client.messages.stream(
model=config.MODEL,
max_tokens=config.MAX_TOKENS,
system=config.PERSONA,
tools=tools.tool_schemas(),
messages=messages,
) as stream:
# Only stream text to the screen; tool requests are handled quietly
for event in stream:
if event.type == "content_block_delta" and event.delta.type == "text_delta":
print(event.delta.text, end="", flush=True)
response = stream.get_final_message()
# If it did not ask for a tool, this turn is done
if response.stop_reason != "tool_use":
print()
return response.content[0].text if response.content else ""
# Otherwise: record the request, run the tools, feed results back, loop
messages.append({"role": "assistant", "content": response.content})
results = []
for block in response.content:
if block.type == "tool_use":
output = tools.run_tool(block.name, block.input)
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": str(output),
})
messages.append({"role": "user", "content": results})
def main():
client = anthropic.Anthropic()
state = memory.load_state()
messages = state["messages"]
if messages:
print(f"Jarvis: Welcome back. We have {len(messages) // 2} past exchanges.\n")
else:
print("Jarvis: Hello. I am ready.\n")
try:
while True:
user_input = input("You: ").strip()
if user_input.lower() in {"quit", "exit", "bye"}:
break
if not user_input:
continue
messages.append({"role": "user", "content": user_input})
messages = memory.trim(messages)
print("Jarvis: ", end="", flush=True)
reply = get_reply(client, messages)
messages.append({"role": "assistant", "content": reply})
print()
except KeyboardInterrupt:
print() # graceful exit on Ctrl+C
finally:
state["messages"] = messages
memory.save_state(state) # always save, even on crash (Lesson 22)
print("Jarvis: Saved. Goodbye.")
if __name__ == "__main__":
main()
Read back over those files. There is nothing in them you did not learn on this campus. Files and JSON from Level 3. Functions and a registry from Level 2. A context manager and a finally from Level 4. The API, streaming, and tools from this level. Environment variables and error handling threaded throughout.
You did not learn 'how to use an AI library'. You learned to program, and an AI assistant turned out to be an ordinary program built from ordinary parts. That is the whole point of the course, and you just proved it to yourself.
Where to take it
| Add | Using | From |
|---|---|---|
| A weather tool | an API call in tools.py | Lesson 42 |
| Answers from your notes | RAG over your documents | Lesson 58 |
| A voice interface | STT and TTS around the loop | Lesson 59 |
| A local-model option | an Ollama backend behind the same interface | Lesson 60 |
| A web interface | FastAPI serving the loop | Lesson 44 |
| A cost meter | token counting per call | Lesson 62 |
| Real tests | pytest over the tools and memory | Lesson 29 |
| A published package | pyproject.toml and a console command | Lesson 50 |
Every one of those is a lesson you have already done, pointed at your own assistant. That is what "extensible" means, and it is why structuring it into clean files was worth the effort.
Ship a first version
Build the four files on your own machine, get it running, and have a real conversation where Jarvis uses the calculator and the clock. Then quit, restart, and confirm it remembers. That is a complete, working, personal AI assistant that you built.
Reveal solution
There is no code to reveal here, because the whole lesson is the code. When it runs, when it remembers you across a restart, when it correctly refuses to guess a number and reaches for the calculator instead, you have finished something real. Take a screenshot. You earned it.
The commit message writes itself: feat: Jarvis speaks, remembers, and acts.
Add your own tool
Add one genuinely useful tool to tools.py: a dice roller, a unit converter, a note-taker that appends to a file, a timer. Add it with a single registry entry and nothing else. Prove the extensibility claim to yourself.
Reveal solution
# add this to tools.py, then add ONE entry to the TOOLS registry
from pathlib import Path
def remember_note(note: str) -> str:
"""Append a note to a file Jarvis can build up over time."""
notes = Path("jarvis_notes.txt")
with open(notes, "a", encoding="utf-8") as f:
f.write(note.strip() + "\n")
total = len(notes.read_text(encoding="utf-8").splitlines())
return f"Noted. You now have {total} notes."
# in the TOOLS dict:
# "remember_note": {
# "fn": remember_note,
# "schema": {
# "name": "remember_note",
# "description": "Save a short note for the user to recall later. "
# "Use when the user says 'remember', 'note', or 'jot down'.",
# "input_schema": {
# "type": "object",
# "properties": {"note": {"type": "string"}},
# "required": ["note"],
# },
# },
# },
print("One function, one registry entry, and Jarvis can now take notes.")Notice you touched only tools.py. The chat loop, the memory, the streaming, none of it changed. That is the payoff of the registry pattern, and it is the difference between code you can grow and code you have to fight. Your Jarvis is now genuinely yours to extend, forever.