DEV Community

Mukunda Rao Katta
Mukunda Rao Katta

Posted on

Persist LLM Conversations Safely: JSONL, Redaction, and Encryption

Multi-Turn Agents Lose State on Crash

A single-turn agent is stateless. Each run starts fresh. If it crashes, you restart it with the same input and try again.

A multi-turn agent is different. It has conversation history. The fifth message depends on messages one through four. If the process crashes between turn four and turn five, and you did not persist anything, you restart from scratch. The user loses context. If the conversation was long, they lose a lot.

Persisting conversation history solves this. But naive persistence has two problems:

  1. The conversation may contain secrets. API keys passed in user messages, PII in model responses, authentication tokens from tool results. Writing those to a plain JSONL file is a data exposure risk.
  2. Plain JSONL sitting on disk is readable by anything with file access. If this is a shared host, a multi-tenant environment, or a device that leaves the building, plaintext history is a liability.

conversation-codec handles both. It writes conversation history to JSONL with an optional redaction pass before writing and optional Fernet encryption at rest. The API covers the read/write lifecycle. You bring the messages; it handles the storage.


Main Code Example

Install:

pip install conversation-codec
Enter fullscreen mode Exit fullscreen mode

For Fernet encryption, install the extras:

pip install "conversation-codec[crypto]"
Enter fullscreen mode Exit fullscreen mode

Basic write and read

from conversation_codec import ConversationCodec

codec = ConversationCodec(path="conversations/session-42.jsonl")

messages = [
    {"role": "user", "content": "What is the revenue for Q1 2024?"},
    {"role": "assistant", "content": "Based on the report, Q1 2024 revenue was $4.2M."},
]

codec.write(messages)

# Later, restore and extend
loaded = codec.read()
loaded.append({"role": "user", "content": "What about Q2?"})
codec.write(loaded)
Enter fullscreen mode Exit fullscreen mode

write() overwrites the file with the full current history. If you want append semantics for streaming:

codec.append({"role": "assistant", "content": "Q2 revenue was $5.1M."})
Enter fullscreen mode Exit fullscreen mode

Redaction before write

Pass a callable that receives each message and returns a cleaned copy:

import re

def redact(message):
    content = message.get("content", "")
    # Redact anything that looks like a bearer token
    content = re.sub(r"Bearer\s+[A-Za-z0-9\-._~+/]+=*", "Bearer [REDACTED]", content)
    # Redact email addresses
    content = re.sub(r"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}", "[EMAIL]", content)
    return {**message, "content": content}

codec = ConversationCodec(
    path="conversations/session-42.jsonl",
    redact=redact,
)

codec.write([
    {"role": "user", "content": "My email is alice@example.com and my token is Bearer eyJhbGc..."},
    {"role": "assistant", "content": "Got it, Alice."},
])
# Written file contains [EMAIL] and Bearer [REDACTED]
Enter fullscreen mode Exit fullscreen mode

The redact callable receives the raw message dict and must return a message dict. Returning None drops the message entirely (useful for filtering tool results that should never persist).

Fernet encryption at rest

from cryptography.fernet import Fernet
from conversation_codec import ConversationCodec

key = Fernet.generate_key()  # store this key securely, not next to the file

codec = ConversationCodec(
    path="conversations/session-42.jsonl",
    fernet_key=key,
)

codec.write(messages)
# File on disk is encrypted ciphertext

loaded = codec.read()  # decrypts on read automatically
Enter fullscreen mode Exit fullscreen mode

Encryption and redaction compose. Pass both and the codec redacts first, then encrypts. On read, it decrypts first, then returns the redacted content.

Resume after crash

import anthropic
from conversation_codec import ConversationCodec

codec = ConversationCodec(path="conversations/session-42.jsonl")
client = anthropic.Anthropic()

# Try to resume. If the file doesn't exist, start fresh.
messages = codec.read() or []

def run_turn(user_input):
    messages.append({"role": "user", "content": user_input})
    response = client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=1024,
        messages=messages,
    )
    assistant_message = {"role": "assistant", "content": response.content[0].text}
    messages.append(assistant_message)
    codec.write(messages)  # persist after every turn
    return assistant_message["content"]

# If this crashes between turns, the next run calls codec.read() and picks up where it left off
run_turn("What is the Q1 revenue?")
run_turn("And Q2?")
Enter fullscreen mode Exit fullscreen mode

Writing after every turn is the key pattern. If you batch writes, you risk losing the turns since the last write. For long-running sessions, this is the right default.


What It Does NOT Do

conversation-codec does not search or query conversation history. If you want to retrieve past conversations by topic, date range, or semantic similarity, you need a vector database or a search index alongside this. The codec writes and reads. It does not index.

It does not deduplicate messages. If you call append() with the same message twice, it writes twice. Deduplication is your responsibility before calling write() or append().

It does not manage keys. You generate and store the Fernet key yourself. If you lose the key, the encrypted file is unreadable. There is no key escrow, no key rotation helper, no key management. This is intentional: key management is a system-level concern that varies by deployment.

It does not version conversation files. If you write a new format and try to read an old file, you may get parse errors. Schema migration is out of scope.

It does not handle concurrent writers. Two processes writing to the same file will corrupt it. Use a process-level lock or a separate file per agent instance.


Design Reasoning

JSONL is the right format for conversations for the same reason it is the right format for logs: it is streamable, appendable, and line-delimited. You can process a JSONL conversation file with jq, grep, or any log aggregation tool without needing to load it all into memory.

The redact callable approach is intentional. Different deployments have different secrets. One system needs to redact bearer tokens. Another needs to redact credit card numbers. A third needs to redact names. A fixed regex would never cover all cases. A callable puts the redaction logic in the caller's hands.

Fernet is a symmetric authenticated encryption scheme from the cryptography package. It encrypts and authenticates in one step. If the file is tampered with, decryption raises an error rather than returning garbage. It is a reasonable default for "encrypt this file on disk" without needing to understand AES modes.

Writing after every turn rather than at the end of the session is the safer default. Sessions end unexpectedly. Writes are cheap. The only reason to batch writes is if you are in a latency-critical path where the file I/O matters, and in that case you already know what you are doing.


When This Applies (and When It Does Not)

Use this when:

  • You have a multi-turn agent that needs to survive process restarts
  • The conversation history may contain secrets or PII that should not persist in plaintext
  • You are on a shared host or in a regulated environment where data-at-rest encryption is required
  • You want the simplest possible conversation persistence without a database dependency

Skip it when:

  • You are already persisting conversations to a database (this is not a database replacement)
  • You need to search, filter, or embed past conversations (add a vector DB alongside this)
  • You are running stateless single-turn agents (no state to persist)
  • You need multi-writer concurrent access (use a database with proper locking instead)

Install or Quick-Start

pip install conversation-codec           # basic JSONL write/read + redaction
pip install "conversation-codec[crypto]" # adds Fernet encryption support
Enter fullscreen mode Exit fullscreen mode

Minimal example:

from conversation_codec import ConversationCodec

codec = ConversationCodec(path="my-session.jsonl")
codec.write([{"role": "user", "content": "Hello"}, {"role": "assistant", "content": "Hi there."}])
print(codec.read())
Enter fullscreen mode Exit fullscreen mode

GitHub: MukundaKatta/conversation-codec


Siblings Table

Library What it does How it pairs with conversation-codec
agenttap Wire-level LLM request/response capture Tap captures full payloads; codec persists the cleaned conversation
agent-citation Claim-level citation tracking Serialize citation store alongside conversation JSONL in the same session dir
llm-redact-secrets Pattern-based secret redaction Use as the redact= callable in ConversationCodec
agent-scratchpad Keyed in-memory notepad for agent state Persist scratchpad state in a separate JSONL alongside conversation
agent-state-checkpoint Durable JSON checkpoint for agent state Checkpoint agent state; codec checkpoints the conversation; both go in the same session dir

What is Next

The next planned feature is partial read support. Today codec.read() loads the entire file. For very long sessions (1000+ turns), that is wasteful if you only need the last 20 turns for the next model call. A codec.read(last_n=20) that reads only the tail of the JSONL file would be more efficient.

Key rotation is also on the roadmap. If you rotate your Fernet key, you need to decrypt the old file and re-encrypt with the new key. A codec.rekey(new_key) helper would handle that without requiring you to implement the read-decrypt-write cycle yourself.

For the Hermes Agent Challenge sprint, conversation-codec is the persistence layer that makes multi-turn agents reliable. Pair it with agenttap for full wire-level traces, agent-citation for claim traceability, and llm-redact-secrets for the redaction callable. Together they give you a conversation that is durable, auditable, and safe to store.

Top comments (0)