Stateless to stateful with JSONL

Real conversations need context. When you say 'make it shorter', the bot needs to know what 'it' refers to. Since the provider is stateless, we have to store the conversation on our side and replay it into every call.

JSONL is one JSON object per line. It is the smallest persistence format that survives a crash mid-write: each line either lands intact or does not land at all. You can cat it, grep it, and tail -f it. Perfect for sessions.

sessions/12345.jsonl
json
{"role": "user", "content": "My name is Param"}
{"role": "assistant", "content": "Nice to meet you, Param!"}
{"role": "user", "content": "What's my name?"}
{"role": "assistant", "content": "Your name is Param."}

One line per turn. Each line is a complete, parseable JSON object. Open it in any editor or pipe it through jq.

02-persistent-sessions/bot.py
python
def load_session(user_id: str) -> list[dict]:
    """Load conversation history from a JSONL file."""
    path = SESSIONS_DIR / f"{user_id}.jsonl"
    if not path.exists():
        return []
    messages = []
    for line in path.read_text().splitlines():
        if line.strip():
            messages.append(json.loads(line))
    return messages


def append_message(user_id: str, message: dict):
    """Append a single message to the user's JSONL session file."""
    path = SESSIONS_DIR / f"{user_id}.jsonl"
    with open(path, "a") as f:
        f.write(json.dumps(message) + "\n")

Two helpers. load_session parses the file line by line. append_message tacks one new line on. No locks, no transactions, no databases.

02-persistent-sessions/bot.py
python
def reply_with_history(user_id: str, user_text: str) -> str:
    """Load history, append user msg, call LLM, save reply, return reply."""
    messages = load_session(user_id)

    user_msg = {"role": "user", "content": user_text}
    messages.append(user_msg)
    append_message(user_id, user_msg)

    response = client.chat.completions.create(
        model=MODEL,
        max_tokens=1024,
        messages=messages,
    )
    reply = response.choices[0].message.content or ""

    assistant_msg = {"role": "assistant", "content": reply}
    append_message(user_id, assistant_msg)

    return reply

The core helper. Load the history, append the user turn, call the model with the full array, save the assistant reply.

Because the data model is a log. Appends only. No queries. No joins. One file per user. SQLite would work, but it adds a process boundary and a connection lifecycle for zero benefit. When we add long-term memory, we use markdown files for the same reason: store data in the shape you read it.

Matching exercise: Match each property to the format that best fits a chat session

Loading practice…

terminal
bash
make 02-persistent-sessions

# you> My name is Param
# bot> Nice to meet you, Param!
# you> What's my name?
# bot> Your name is Param.

cat 02-persistent-sessions/sessions/cli-user.jsonl

Run the new bot, have a tiny conversation, then poke around in the session file.

Quiz: Quiz

Loading practice…