Storage
ProtoLink provides pluggable storage for Agent state, process-local caches, and
durable execution records. Persistence depends on the selected backend:
SQLiteStorage survives restarts, while InMemoryStorage intentionally does
not.
Storage Types
Protolink currently supports the following storage implementations:
SQLiteStorage- namespaced JSON key/value persistence in a local SQLite database.InMemoryStorage- process-local object storage with optional sliding time-to-live expiration.SQLiteRunStore- indexed task snapshots and run reports for replay, audit, and regression workflows.
You can implement a custom state backend by subclassing Storage, or implement
the structural RunStore protocol when execution records belong in an
application database.
Configuration
Using storage with an agent is straightforward:
-
Instantiate the Storage implementation:
from protolink.storage import SQLiteStoragestorage = SQLiteStorage(db_path="agent_memory.db",namespace="my_agent") -
Pass the Storage instance to your Agent:
from protolink.agents import Agentfrom protolink.models import AgentCardagent_card = AgentCard(url="http://localhost:8020",name="memory_agent",description="Agent with long-term memory")agent = Agent(card=agent_card,transport="http",storage=storage,state=["conversation"] # Enables persistence for specific modules)
The namespace parameter in SQLiteStorage allows you to isolate data for different agents or contexts within the same database file.
Storage API Reference
This section provides a detailed API reference for the Storage module.
Protolink provides a consistent CRUD interface for all storage backends. Whether you are using SQLite, a cloud database, or a simple JSON file, you interact with them through the same standard methods: save(), load(), update(), and delete().
The storage surfaces used for namespaced Agent state, process-local TTL values, registry persistence, and indexed execution records.
protolink.storageStorageSQLiteStorageInMemoryStorageSQLiteRunStorestate=[...]Generic storage contract
Storage
class StorageDefine the synchronous, single-value persistence contract used by ProtoLink state modules. A storage instance represents one logical namespace: callers save or replace its complete value, load it, or delete it.
Abstract methods
save(data: Any) -> NonePersist a complete namespace value.
load() -> AnyReturn the stored value, normally
Nonewhen absent.update(data: Any) -> NoneReplace or otherwise update the namespace according to backend semantics.
delete() -> NoneRemove the namespace value.
These methods are ordinary blocking functions. A database or network-backed custom implementation should manage blocking I/O appropriately when called from an asynchronous Agent.
Storage.save
save(
data: Any,
) -> NonePersist the supplied value as the current contents of this storage namespace.
Parameters
dataAnyrequiredBackend-specific value. Implementations decide whether it must be serializable, whether it is copied, and whether saving replaces existing data.
Returns
NoneNonePersistence is performed for its side effect.
Storage.load
load() -> AnyLoad the current namespace value.
Returns
dataAnyBackend-specific value, conventionally
Nonewhen no value is stored. The interface cannot distinguish an absent value from an explicitly storedNone.
Storage.update
update(
data: Any,
) -> NoneUpdate the namespace using backend-specific semantics. Both built-in
implementations make this exactly equivalent to save(); it is not a partial
dictionary merge.
Parameters
dataAnyrequiredNew complete value for the built-in backends.
Returns
NoneNoneUpdating is performed for its side effect.
Storage.delete
delete() -> NoneRemove the current namespace value.
Returns
NoneNoneBuilt-in deletion is idempotent: deleting a missing namespace is not an error.
SQLite key/value storage
SQLiteStorage
class SQLiteStorage(
db_path: str = "storage.db",
table_name: str = "storage",
namespace: str = "default",
)Persist one JSON-serializable value per namespace in a small SQLite table. Construction validates the table identifier, opens or creates the database, and creates the table when it is missing.
Parameters
db_pathstrdefault: "storage.db"SQLite database path. SQLite creates the file when possible; the parent directory itself is not created by this class.
table_namestrdefault: "storage"Table containing
keyand JSONvaluecolumns. It must satisfy Python'sstr.isidentifier()check before it is interpolated into SQL.namespacestrdefault: "default"Primary key for this storage instance's value. Multiple instances can share a database and table while using different namespaces.
Attributes
db_pathstrConfigured database path.
table_namestrValidated table identifier.
namespacestrActive row key.
Raises
ValueErrorRaised for a table name that is not a valid Python identifier.
sqlite3.ErrorDatabase creation, connection, schema, permission, and locking errors propagate.
Construction and every CRUD call open a short-lived SQLite connection. ProtoLink does not configure busy timeouts, WAL mode, migrations, encryption, or cross-process coordination for this minimal adapter.
SQLiteStorage.save
save(
data: Any,
) -> NoneJSON-encode a value and insert or replace the row for the active namespace.
Parameters
dataAnyrequiredValue accepted by Python's
json.dumps(). Tuple and other JSON conversions follow standard-library behavior.
Returns
NoneNoneThe transaction is committed before the method returns.
Raises
TypeError / ValueErrorJSON serialization errors propagate before the database write.
sqlite3.ErrorConnection, locking, statement, and commit errors propagate.
SQLiteStorage.load
load() -> AnyRead and JSON-decode the current namespace row.
Returns
dataAnyDeserialized JSON value, or
Nonewhen the namespace has no row. An explicitly saved JSONnullalso loads asNone.
Raises
json.JSONDecodeErrorRaised if another writer or manual edit stored invalid JSON.
sqlite3.ErrorDatabase connection and query errors propagate.
SQLiteStorage.update
update(
data: Any,
) -> NoneReplace the namespace value by calling save(data). The operation is an upsert,
so it also creates a row that does not already exist.
Parameters
dataAnyrequiredComplete JSON-serializable replacement value.
Returns
NoneNoneReturns after the delegated save transaction commits.
SQLiteStorage.delete
delete() -> NoneDelete the row for the active namespace and commit the transaction.
Returns
NoneNoneMissing rows are ignored, making deletion idempotent.
Raises
sqlite3.ErrorConnection, locking, statement, and commit errors propagate.
In-memory storage
InMemoryStorage
class InMemoryStorage(
namespace: str = "default",
ttl: int | None = None,
store: dict[str, tuple[Any, float]] | None = None,
ttl_heap: list[tuple[float, str]] | None = None,
)Keep arbitrary Python objects in a dictionary-backed namespace. Loads use sliding expiration: a successful access refreshes the entry timestamp and pushes a new expiration marker when TTL is enabled.
Parameters
namespacestrdefault: "default"Dictionary key owned by this wrapper.
ttlint | Nonedefault: NoneSliding lifetime in seconds.
Nonedisables expiration. Values are stored without validation, so zero or negative values expire on the next sufficiently later access.storedict[str, tuple[Any, float]] | Nonedefault: NoneOptional backing dictionary of values and last-touch timestamps. When omitted, all default instances share a class-level process store.
ttl_heaplist[tuple[float, str]] | Nonedefault: NoneOptional min-heap of expiration timestamps and namespaces. Pair a custom store with its own heap whenever TTL is enabled; otherwise save/load markers for the custom store enter the unrelated class-global heap.
Attributes
namespacestrActive dictionary key.
ttlint | NoneSliding expiration configured on this wrapper.
Values are stored and returned by reference, not copied. The default backing
dictionary and heap are shared across instances and are not synchronized for
concurrent threads. cleanup_expired() applies the calling
instance's TTL while inspecting the shared heap, so instances sharing a
backing store should use one consistent TTL policy.
InMemoryStorage.save
save(
data: Any,
) -> NoneStore the object reference with the current wall-clock timestamp and, when TTL is enabled, push its calculated expiration onto the heap.
Parameters
dataAnyrequiredAny Python object. No serialization or copy is performed.
Returns
NoneNoneExisting namespace values are replaced.
InMemoryStorage.load
load() -> AnyReturn the current object when present and unexpired. Successful reads refresh the timestamp, making the configured TTL idle-based rather than a fixed time-since-save limit.
Returns
dataAnyThe exact stored object reference, or
Nonewhen missing or expired. An expired entry is removed lazily.
Every successful load rewrites the entry timestamp. With TTL enabled it also
adds a new heap marker; stale older markers are discarded later by
cleanup_expired().
InMemoryStorage.update
update(
data: Any,
) -> NoneReplace or create the namespace value through save(data), resetting its last
touch and expiration marker.
Parameters
dataAnyrequiredComplete replacement object.
Returns
NoneNoneThe method has the same side effects as
save().
InMemoryStorage.delete
delete() -> NoneRemove the active namespace from the backing dictionary.
Returns
NoneNoneMissing namespaces are ignored. Existing heap markers are left in place and discarded as stale during future cleanup.
InMemoryStorage.cleanup_expired
cleanup_expired() -> intPop elapsed heap markers and proactively remove entries whose latest stored timestamp is also older than the calling instance's TTL.
Returns
removedintNumber of backing-store entries deleted. Returns zero when the heap is empty, TTL is disabled on the caller, or only stale markers were found.
Complexity
timeO(M log N)Mis the number of elapsed heap markers andNis heap size.spaceO(N)Repeated touches can temporarily create multiple markers for one namespace.
Durable execution records
Generic Storage holds the current value for one state namespace. RunStore
solves a different problem: it preserves indexed task snapshots and normalized
run reports so an application can retrieve, replay, compare, or audit past
executions.
Open a local run store with protolink dashboard --store runs.db --open.
TaskRecord
class TaskRecord(
task_id: str,
state: str,
run_id: str | None = None,
session_id: str | None = None,
trace_id: str | None = None,
agent_name: str | None = None,
task: dict[str, Any] = field(default_factory=dict),
metadata: dict[str, Any] = field(default_factory=dict),
created_at: str | None = None,
updated_at: str = field(default_factory=utc_now),
)Represent the searchable index fields and serialized payload for one persisted task snapshot.
Parameters
task_idstrrequiredStable task identifier and SQLite primary key.
statestrrequiredSerialized task lifecycle state.
run_idstr | Nonedefault: NoneCorrelated logical execution run.
session_idstr | Nonedefault: NoneCorrelated application or conversation session.
trace_idstr | Nonedefault: NoneCorrelated observability trace.
agent_namestr | Nonedefault: NoneAgent name supplied when the snapshot was saved.
taskdict[str, Any]default: {}Serialized
Task.to_dict()payload.metadatadict[str, Any]default: {}Caller-owned index-record metadata, separate from fields inside the task.
created_atstr | Nonedefault: NoneTimestamp copied from the task when available.
updated_atstrdefault: utc_now()Timestamp of this persisted snapshot.
Field assignment is blocked, but the nested task and
metadata dictionaries remain mutable.
TaskRecord.to_dict
to_dict() -> dict[str, Any]Return all task-record fields in a serialization-friendly mapping.
Returns
recorddict[str, Any]New outer dictionary containing the ten record fields. Nested task and metadata dictionaries are reused rather than deep-copied.
RunReportRecord
class RunReportRecord(
run_id: str,
session_id: str | None = None,
trace_id: str | None = None,
agent_name: str | None = None,
report: dict[str, Any] = field(default_factory=dict),
metadata: dict[str, Any] = field(default_factory=dict),
created_at: str = field(default_factory=utc_now),
)Represent the indexed identity and serialized payload for one persisted
RunReport.
Parameters
run_idstrrequiredLogical run identifier and SQLite primary key.
session_idstr | Nonedefault: NoneSession copied from the report context when available.
trace_idstr | Nonedefault: NoneTrace copied from the report context when available.
agent_namestr | Nonedefault: NoneAgent name supplied by the saving application.
reportdict[str, Any]default: {}Serialized
RunReport.to_dict()payload.metadatadict[str, Any]default: {}Caller-owned record metadata.
created_atstrdefault: utc_now()Timestamp at which the record was persisted.
The record is frozen, while its nested report and metadata dictionaries remain mutable references.
RunReportRecord.to_dict
to_dict() -> dict[str, Any]Return all run-report record fields in a new outer mapping.
Returns
recorddict[str, Any]Mapping containing run/session/trace/agent identity, report payload, metadata, and creation time. Nested mappings are not deep-copied.
RunStore
class RunStore(Protocol)Define the structural interface an Agent can use for durable task and report records. Implementations do not need to inherit from this protocol, and the protocol itself cannot be instantiated.
Task methods
save_task(Task, *, context=None, agent_name=None, metadata=None) -> TaskRecordPersist or replace a task snapshot.
get_task(task_id: str) -> Task | NoneReconstruct a task payload by ID.
get_task_record(task_id: str) -> TaskRecord | NoneLoad the serialized record and index metadata by ID.
list_task_records(*, limit=100, session_id=None, run_id=None, state=None, agent_name=None) -> list[TaskRecord]Query recent task records using optional indexed filters.
Report methods
save_report(RunReport, *, run_id=None, agent_name=None, metadata=None) -> RunReportRecordPersist or replace a normalized run report.
get_report(run_id: str) -> RunReport | NoneReconstruct a report by run ID.
get_report_record(run_id: str) -> RunReportRecord | NoneLoad the serialized report record by ID.
list_report_records(*, limit=100, session_id=None, agent_name=None) -> list[RunReportRecord]Query recent report records using optional indexed filters.
Deletion is not required by RunStore.
SQLiteRunStore adds delete_task() and
delete_report() as concrete administrative extensions.
SQLiteRunStore
class SQLiteRunStore(
db_path: str | Path = "runs.db",
*,
table_prefix: str = "protolink",
)Implement RunStore with two SQLite tables: one for task snapshots and one for
run reports. JSON payload columns retain complete serialized objects, while
relational columns index common lookup fields.
Parameters
db_pathstr | Pathdefault: "runs.db"SQLite database path, converted to
str. The database file and schema are created when missing; parent directories are not created.table_prefixstrdefault: "protolink"Prefix used to create
<prefix>_tasksand<prefix>_run_reports. It must satisfystr.isidentifier().
Attributes
db_pathstrNormalized database path.
table_prefixstrValidated table prefix.
tasks_tablestrResolved task table name.
reports_tablestrResolved run-report table name.
Raises
ValueErrorRaised for an invalid table-prefix identifier.
sqlite3.ErrorConnection, schema creation, index creation, permission, and locking errors propagate.
Tasks are keyed by task_id and reports by run_id.
Saves use SQLite INSERT OR REPLACE, so a repeated identifier
replaces the full prior row.
SQLiteRunStore.save_task
save_task(
task: Task,
*,
context: RunContext | None = None,
agent_name: str | None = None,
metadata: dict[str, Any] | None = None,
) -> TaskRecordSerialize and upsert one task snapshot together with indexed run correlation.
Parameters
taskTaskrequiredTask whose ID, state, creation time, and complete
to_dict()payload are persisted.contextRunContext | Nonedefault: NoneExplicit run/session/trace source. When omitted,
RunContext.from_task(task)derives one from task metadata.agent_namestr | Nonedefault: NoneOptional indexed agent identity.
metadatadict[str, Any] | Nonedefault: NoneOptional record metadata. A shallow dictionary copy is made before serialization.
Returns
recordTaskRecordFrozen record matching the row that was committed.
Raises
serialization errorErrors from task serialization or
json.dumps()propagate.sqlite3.ErrorConnection, statement, locking, and commit errors propagate.
SQLiteRunStore.get_task
get_task(
task_id: str,
) -> Task | NoneLoad a task record and reconstruct its domain model.
Parameters
task_idstrrequiredPrimary-key identifier.
Returns
taskTask | NoneTask.from_dict(record.task), orNonewhen no row exists.
Raises
deserialization or sqlite errorInvalid stored JSON/task payloads and database failures propagate.
SQLiteRunStore.get_task_record
get_task_record(
task_id: str,
) -> TaskRecord | NoneLoad the indexed record without reconstructing a Task.
Parameters
task_idstrrequiredPrimary-key identifier.
Returns
recordTaskRecord | NoneParsed task record, or
Nonewhen absent.
SQLiteRunStore.list_task_records
list_task_records(
*,
limit: int = 100,
session_id: str | None = None,
run_id: str | None = None,
state: str | TaskState | None = None,
agent_name: str | None = None,
) -> list[TaskRecord]Query task records with conjunctive optional filters, ordered by newest
updated_at first.
Parameters
limitintdefault: 100SQLite result limit. Values are not validated; zero returns no rows and SQLite treats a negative limit as unbounded.
session_idstr | Nonedefault: NoneMatch one session exactly.
run_idstr | Nonedefault: NoneMatch one run exactly.
statestr | TaskState | Nonedefault: NoneMatch the enum's
.valueor the string representation of the supplied value.agent_namestr | Nonedefault: NoneMatch one stored agent name exactly.
Returns
recordslist[TaskRecord]Matching records in descending lexicographic ISO timestamp order.
SQLiteRunStore.save_report
save_report(
report: RunReport,
*,
run_id: str | None = None,
agent_name: str | None = None,
metadata: dict[str, Any] | None = None,
) -> RunReportRecordSerialize and upsert one complete run report.
Parameters
reportRunReportrequiredReport serialized through
to_dict(). Its context supplies session and trace index fields when present.run_idstr | Nonedefault: NoneOptional primary-key override. When omitted, the report context's run ID is used.
agent_namestr | Nonedefault: NoneOptional indexed agent identity.
metadatadict[str, Any] | Nonedefault: NoneOptional record metadata, shallow-copied before serialization.
Returns
recordRunReportRecordFrozen record matching the committed row.
Raises
ValueErrorRaised when neither an explicit run ID nor a report-context run ID is available.
serialization or sqlite errorReport serialization, JSON encoding, database, and commit errors propagate.
An explicit run_id changes the record key; it does not rewrite
the run ID already serialized inside the report payload.
SQLiteRunStore.get_report
get_report(
run_id: str,
) -> RunReport | NoneLoad a report record and reconstruct the RunReport.
Parameters
run_idstrrequiredPrimary-key identifier.
Returns
reportRunReport | NoneRunReport.from_dict(record.report), orNonewhen absent.
SQLiteRunStore.get_report_record
get_report_record(
run_id: str,
) -> RunReportRecord | NoneLoad the indexed and serialized record without reconstructing a RunReport.
Parameters
run_idstrrequiredPrimary-key identifier.
Returns
recordRunReportRecord | NoneParsed record, or
Nonewhen absent.
SQLiteRunStore.list_report_records
list_report_records(
*,
limit: int = 100,
session_id: str | None = None,
agent_name: str | None = None,
) -> list[RunReportRecord]Query recent run-report records with optional session and agent filters.
Parameters
limitintdefault: 100SQLite result limit; it is not range-validated.
session_idstr | Nonedefault: NoneMatch one session exactly.
agent_namestr | Nonedefault: NoneMatch one agent name exactly.
Returns
recordslist[RunReportRecord]Matching records ordered by newest
created_atfirst.
SQLiteRunStore.delete_task
delete_task(
task_id: str,
) -> NoneDelete one task snapshot by primary key.
Parameters
task_idstrrequiredTask identifier to remove.
Returns
NoneNoneThe transaction commits even when no matching row existed.
SQLiteRunStore.delete_report
delete_report(
run_id: str,
) -> NoneDelete one run report by primary key.
Parameters
run_idstrrequiredRun identifier to remove.
Returns
NoneNoneThe transaction commits even when no matching row existed.
Usage Examples
Standalone Usage
You can use the storage module independently of the agent system.
from protolink.storage import SQLiteStorage
# Initialize storage
storage = SQLiteStorage(db_path="data.db", namespace="user_settings")
# Save some data
settings = {"theme": "dark", "notifications": True}
storage.save(settings)
# Load data later
loaded_settings = storage.load()
print(loaded_settings["theme"]) # Output: dark
# Update data
loaded_settings["notifications"] = False
storage.update(loaded_settings)
# Delete data
storage.delete()
In-Memory TTL Usage
InMemoryStorage is useful when state should disappear with the process or
after a period of inactivity:
from protolink.storage import InMemoryStorage
cache = InMemoryStorage(namespace="session-42", ttl=300)
cache.save({"messages": 4})
# A successful load refreshes the five-minute idle timeout.
value = cache.load()
# Use the same backing store, heap, and TTL policy when pruning shared entries.
removed = cache.cleanup_expired()
Durable Run Records
Use SQLiteRunStore for execution history rather than mutable Agent state:
from protolink import Message, RunContext, Task
from protolink.storage import SQLiteRunStore
run_store = SQLiteRunStore("runs.db")
task = Task.create(
Message(role="user").add_text("prepare the release notes")
)
context = RunContext(run_id="release-2026-07", session_id="release")
record = run_store.save_task(
task,
context=context,
agent_name="release-writer",
metadata={"environment": "staging"},
)
recent = run_store.list_task_records(session_id="release", limit=20)
Agent Memory Integration
Agents can use the storage field to persist their state, conversation context, or learned information.
from protolink.agents import Agent
from protolink.storage import SQLiteStorage
class PersistentAgent(Agent):
async def handle_task(self, task):
# Load previous state
state = self.storage.load() or {"count": 0}
# Increment a counter
state["count"] += 1
# Save updated state
self.storage.save(state)
return await super().handle_task(task)
State System Integration (v0.5.5+)
Starting with version v0.5.5, ProtoLink includes a unified State system.
When you provide a storage instance and enable conversation, the
conversation module automatically performs whole-payload load() and save()
operations. The tools, task, and flow modules currently expose
storage-backed extension points; applications define their own persistence
conventions on top.
| Module | Storage Usage |
|---|---|
| conversation | Stores a serialized map of session_id to ConversationHistory lists. |
| tools | Retains the shared Storage reference but currently exposes no public persistence methods. |
| task | Retains the shared Storage reference but currently exposes no public persistence methods. |
| flow | to_dict() reads the shared storage payload; applications own any write/checkpoint convention. |
This high-level system is the recommended way to manage LLM conversation
persistence. Enabled modules currently receive the same Storage object rather
than hidden per-module namespaces, so applications combining module-specific
data should partition it explicitly.
Error Handling
Storage errors are intentionally visible to the caller:
- Connection and SQL errors: context managers close short-lived SQLite
connections, but
sqlite3.Errorsubclasses still propagate. - Serialization errors: non-JSON-serializable data raises from
json.dumps()before the write commits. - Deserialization errors: malformed stored JSON raises from
json.loads(). - File permissions and missing parent directories: both SQLite adapters rely on the configured path being writable; neither creates parent directories.
- Concurrent writes: SQLite locking and busy errors are not retried by the storage classes.