Level 6 · Build Your Own Jarvis

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.

🏗️ This is a spec, not a script

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()
VOLITION[Godly: Success]

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

AddUsingFrom
A weather toolan API call in tools.pyLesson 42
Answers from your notesRAG over your documentsLesson 58
A voice interfaceSTT and TTS around the loopLesson 59
A local-model optionan Ollama backend behind the same interfaceLesson 60
A web interfaceFastAPI serving the loopLesson 44
A cost metertoken counting per callLesson 62
Real testspytest over the tools and memoryLesson 29
A published packagepyproject.toml and a console commandLesson 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.

Exercise 1

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.

Exercise 2

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.

+100 XP