agent-scratchpad
v0.1.2
Published
Lightweight key-value scratchpad for agent reasoning
Readme
agent-scratchpad
Lightweight key-value scratchpad for AI agent working memory.
agent-scratchpad is a zero-dependency, in-process key-value store purpose-built for AI agent reasoning loops. Agents executing multi-step workflows (ReAct, Plan-and-Execute, Chain-of-Thought with tool use) need a place to write down intermediate state between steps -- tool outputs, extracted entities, partial computations, decision rationale, and task decomposition state. This package provides that working memory with typed entries, automatic TTL-based expiration, hierarchical namespaces, tag-based querying, point-in-time snapshots, event-driven change observation, pluggable persistence, and a toContext() method that renders scratchpad contents directly into LLM prompts. It works with any agent framework or custom agent loop.
Installation
npm install agent-scratchpadQuick Start
import { createScratchpad } from 'agent-scratchpad';
const pad = createScratchpad();
// Store intermediate results
pad.set('step', 'analyze');
pad.set('user', { id: 42, name: 'Alice' });
console.log(pad.get('step')); // 'analyze'
console.log(pad.has('user')); // true
console.log(pad.keys()); // ['step', 'user']
// Render contents for an LLM prompt
const context = pad.toContext({ format: 'markdown' });Features
- Zero runtime dependencies -- all logic uses built-in JavaScript APIs
- TypeScript-first -- full generic type safety on
get<T>()andset<T>() - TTL expiration -- fixed or sliding time-to-live with lazy and active sweep modes
- Hierarchical namespaces -- scope entries per agent, task, or step with
pad.namespace('name') - Tag-based querying -- label entries and retrieve them with
findByTag() - Snapshots -- capture and restore full scratchpad state for backtracking and debugging
- Context rendering -- format entries as Markdown, XML, JSON, or key-value pairs for LLM prompts
- Event system -- observe
set,delete,expire, andclearevents - Pluggable persistence -- save and load scratchpad state via a simple adapter interface
- Framework-agnostic -- works with LangChain, Vercel AI SDK, AutoGen, CrewAI, or any custom loop
API Reference
createScratchpad(options?)
Creates a new Scratchpad instance.
import { createScratchpad } from 'agent-scratchpad';
const pad = createScratchpad({
defaultTtl: 60_000,
defaultSlidingTtl: false,
sweepIntervalMs: 10_000,
now: () => Date.now(),
persistence: adapter,
});Parameters:
| Option | Type | Default | Description |
|---|---|---|---|
| defaultTtl | number \| null | null | Default TTL in milliseconds applied to entries that do not specify their own. null means no expiration. |
| defaultSlidingTtl | boolean | false | Whether the default TTL mode is sliding (resets on access) or fixed (from creation). |
| sweepIntervalMs | number \| null | null | Interval in milliseconds for proactive background sweep of expired entries. null disables active sweep. |
| now | () => number | Date.now | Custom time source. Useful for deterministic testing. |
| persistence | PersistenceAdapter | undefined | Optional adapter for saving and loading scratchpad state. |
Returns: Scratchpad
fromSnapshot(snapshot, options?)
Creates a new Scratchpad pre-populated from a previously captured snapshot.
import { fromSnapshot } from 'agent-scratchpad';
const pad = fromSnapshot(snap, { defaultTtl: 30_000 });Parameters:
snapshot(ScratchpadSnapshot) -- A snapshot object previously obtained frompad.snapshot()orpad.serialize().options(ScratchpadOptions, optional) -- Configuration options passed to the underlyingcreateScratchpad()call.
Returns: Scratchpad
Scratchpad Methods
set<T>(key, value, options?)
Stores a value under the given key. If the key already exists, updates the value and updatedAt timestamp while preserving createdAt.
pad.set('result', { score: 0.95 });
pad.set('cache', 'value', { ttl: 5_000, tags: ['temporary'] });
pad.set('session', token, { ttl: 30_000, slidingTtl: true });Parameters:
key(string) -- The entry key.value(T) -- The value to store.options(EntryOptions, optional) -- Per-entry configuration.
| Option | Type | Default | Description |
|---|---|---|---|
| ttl | number \| null | Inherits defaultTtl | TTL in milliseconds. null disables expiration for this entry. |
| slidingTtl | boolean | Inherits defaultSlidingTtl | Whether TTL resets on each get() access. |
| tags | string[] | [] | String labels for categorizing the entry. |
Returns: void
get<T>(key)
Retrieves the value for a key. If the entry has expired, it is removed, an expire event fires, and undefined is returned. On a successful read, accessedAt is updated (which resets the sliding TTL window if applicable).
const user = pad.get<{ id: number; name: string }>('user');Parameters:
key(string) -- The entry key.
Returns: T | undefined
has(key)
Checks whether a key exists and is not expired. Expired entries are removed and trigger an expire event.
if (pad.has('apiResponse')) {
// entry is live
}Parameters:
key(string) -- The entry key.
Returns: boolean
delete(key)
Removes an entry by key. Fires a delete event if the entry existed.
const removed = pad.delete('staleData'); // true if it existedParameters:
key(string) -- The entry key.
Returns: boolean -- true if the entry existed and was removed, false otherwise.
clear()
Removes all entries from the scratchpad. Fires a clear event with the count of removed entries.
pad.clear();Returns: void
keys()
Returns an array of all non-expired keys. Expired entries encountered during iteration are excluded.
const allKeys = pad.keys(); // ['step', 'user', 'result']Returns: string[]
entries()
Returns an array of [key, ScratchpadEntry] tuples for all non-expired entries.
for (const [key, entry] of pad.entries()) {
console.log(key, entry.value, entry.tags);
}Returns: [string, ScratchpadEntry][]
findByTag(tag)
Returns all non-expired entries whose tags array includes the given tag (exact match).
pad.set('london', 'UK capital', { tags: ['geo', 'important'] });
pad.set('paris', 'France capital', { tags: ['geo'] });
const geoEntries = pad.findByTag('geo'); // both entriesParameters:
tag(string) -- The tag to search for.
Returns: ScratchpadEntry[]
namespace(name)
Returns a scoped view of the scratchpad where all operations are prefixed with name:. Namespaces share the underlying storage with the parent -- they are views, not copies. Namespaces can be nested.
const memory = pad.namespace('memory');
memory.set('fact', 'The sky is blue');
memory.get('fact'); // 'The sky is blue'
pad.get('memory:fact'); // 'The sky is blue'
// Nested namespaces compose prefixes
const deep = pad.namespace('a').namespace('b');
deep.set('key', 'val');
pad.get('a:b:key'); // 'val'
// Namespace-scoped operations
memory.keys(); // ['fact'] (prefix stripped)
memory.clear(); // removes only memory:* entriesParameters:
name(string) -- The namespace prefix.
Returns: Scratchpad -- A namespace-scoped scratchpad instance with the same full API.
snapshot()
Captures the full scratchpad state at the current point in time. The returned snapshot is a plain object suitable for serialization.
const snap = pad.snapshot();
// { entries: { ... }, timestamp: 1710000000000, version: 1 }Returns: ScratchpadSnapshot
restore(snapshot)
Replaces the entire scratchpad state with the contents of a snapshot. Clears all existing entries before restoring. Throws ScratchpadVersionError if the snapshot version is not supported.
pad.restore(snap);Parameters:
snapshot(ScratchpadSnapshot) -- A snapshot previously obtained fromsnapshot()orserialize().
Returns: void
Throws: ScratchpadVersionError if snapshot.version is not 1.
serialize()
Alias for snapshot(). Returns the same ScratchpadSnapshot structure.
const data = pad.serialize();Returns: ScratchpadSnapshot
toContext(options?)
Renders scratchpad contents as a formatted string suitable for injection into an LLM prompt. Supports filtering by tags or namespace, multiple output formats, token budget limits, and custom headers.
pad.set('name', 'Alice');
pad.set('role', 'admin');
pad.toContext();
// 'name: Alice\nrole: admin'
pad.toContext({ format: 'markdown' });
// '## name\nAlice\n\n## role\nadmin'
pad.toContext({ format: 'json' });
// '{"name":"Alice","role":"admin"}'
pad.toContext({ format: 'xml' });
// '<entry key="name">Alice</entry>\n<entry key="role">admin</entry>'Parameters:
| Option | Type | Default | Description |
|---|---|---|---|
| format | 'kv' \| 'markdown' \| 'xml' \| 'json' | 'kv' | Output format. |
| filterTags | string[] | undefined | Only include entries that have at least one of the specified tags. |
| filterNamespace | string | undefined | Only include entries whose key starts with the given namespace prefix. |
| maxTokens | number | undefined | Truncate output to fit within this token budget. |
| tokenCounter | (text: string) => number | text.length | Function to count tokens. Used with maxTokens. |
| includeMetadata | boolean | undefined | Reserved for future use. |
| header | string | undefined | Text prepended to the output before the formatted entries. |
Returns: string
stats()
Returns aggregate statistics about the scratchpad's current state.
const st = pad.stats();
// {
// size: 3, // live (non-expired) entry count
// rawSize: 4, // total entries including expired-not-yet-swept
// namespaceCount: 2,
// namespaces: ['ctx', 'mem'],
// entriesWithTtl: 1,
// tagCounts: { geo: 2, important: 1 },
// oldestEntryAt: 1710000000000,
// newestEntryAt: 1710000001000,
// }Returns: ScratchpadStats
| Field | Type | Description |
|---|---|---|
| size | number | Count of non-expired entries. |
| rawSize | number | Total entries in the store, including expired entries not yet swept. |
| namespaceCount | number | Number of distinct namespace prefixes. |
| namespaces | string[] | List of distinct namespace prefixes. |
| entriesWithTtl | number | Count of entries that have a TTL set. |
| tagCounts | Record<string, number> | Count of entries per tag. |
| oldestEntryAt | number \| null | createdAt of the oldest live entry, or null if empty. |
| newestEntryAt | number \| null | createdAt of the newest live entry, or null if empty. |
on(event, handler)
Registers an event handler. Returns an unsubscribe function.
const unsub = pad.on('set', ({ key, entry, isUpdate }) => {
console.log(isUpdate ? 'updated' : 'created', key);
});
pad.on('delete', ({ key, entry }) => {
console.log('deleted', key);
});
pad.on('expire', ({ key, entry }) => {
console.log('expired', key);
});
pad.on('clear', ({ count }) => {
console.log('cleared', count, 'entries');
});
// Stop listening
unsub();Parameters:
event(ScratchpadEventName) -- One of'set','delete','expire','clear'.handler(ScratchpadEventHandler<K>) -- Callback receiving the event payload.
Event Payloads:
| Event | Payload |
|---|---|
| set | { key: string; entry: ScratchpadEntry; isUpdate: boolean } |
| delete | { key: string; entry: ScratchpadEntry } |
| expire | { key: string; entry: ScratchpadEntry } |
| clear | { count: number } |
Returns: () => void -- Call to unsubscribe.
save()
Persists the current scratchpad state using the configured PersistenceAdapter. No-op if no adapter was provided.
await pad.save();Returns: Promise<void>
load()
Loads scratchpad state from the configured PersistenceAdapter and restores it. No-op if no adapter was provided or the adapter returns null.
await pad.load();Returns: Promise<void>
destroy()
Cleans up resources. Stops the background sweep timer if one is running.
await pad.destroy();Returns: Promise<void>
TTL Utility Functions
isExpired(entry, now)
Determines whether a scratchpad entry has expired based on its TTL configuration.
import { isExpired } from 'agent-scratchpad';
const expired = isExpired(entry, Date.now());Parameters:
entry(ScratchpadEntry) -- The entry to check.now(number) -- Current timestamp in milliseconds.
Returns: boolean -- true if the entry's TTL has elapsed.
Logic:
- Returns
falseifentry.ttlisnull. - For fixed TTL (
slidingTtl: false): expired whennow >= entry.createdAt + entry.ttl. - For sliding TTL (
slidingTtl: true): expired whennow >= entry.accessedAt + entry.ttl.
expiresAt(entry)
Calculates the absolute expiration timestamp for an entry.
import { expiresAt } from 'agent-scratchpad';
const expiry = expiresAt(entry); // number | nullParameters:
entry(ScratchpadEntry) -- The entry to inspect.
Returns: number | null -- The Unix timestamp (ms) when the entry expires, or null if it has no TTL.
Types
ScratchpadEntry<T>
interface ScratchpadEntry<T = unknown> {
key: string;
value: T;
createdAt: number; // Unix ms when first created
updatedAt: number; // Unix ms when value last updated
accessedAt: number; // Unix ms when last read via get()
ttl: number | null; // TTL in ms, null = no expiration
slidingTtl: boolean; // true = TTL resets on access
tags: string[]; // string labels for categorization
}EntryOptions
interface EntryOptions {
ttl?: number | null;
slidingTtl?: boolean;
tags?: string[];
}ScratchpadOptions
interface ScratchpadOptions {
defaultTtl?: number | null;
defaultSlidingTtl?: boolean;
sweepIntervalMs?: number | null;
now?: () => number;
persistence?: PersistenceAdapter;
}ScratchpadSnapshot
interface ScratchpadSnapshot {
entries: Record<string, ScratchpadEntry>;
timestamp: number;
version: 1;
}ScratchpadStats
interface ScratchpadStats {
size: number;
rawSize: number;
namespaceCount: number;
namespaces: string[];
entriesWithTtl: number;
tagCounts: Record<string, number>;
oldestEntryAt: number | null;
newestEntryAt: number | null;
}ToContextOptions
interface ToContextOptions {
format?: 'markdown' | 'xml' | 'json' | 'kv';
filterTags?: string[];
filterNamespace?: string;
maxTokens?: number;
tokenCounter?: (text: string) => number;
includeMetadata?: boolean;
header?: string;
}PersistenceAdapter
interface PersistenceAdapter {
load(): Promise<ScratchpadSnapshot | null>;
save(snap: ScratchpadSnapshot): Promise<void>;
}ScratchpadEvents
interface ScratchpadEvents {
set: { key: string; entry: ScratchpadEntry; isUpdate: boolean };
delete: { key: string; entry: ScratchpadEntry };
expire: { key: string; entry: ScratchpadEntry };
clear: { count: number };
}ScratchpadEventName
type ScratchpadEventName = 'set' | 'delete' | 'expire' | 'clear';ScratchpadEventHandler<K>
type ScratchpadEventHandler<K extends ScratchpadEventName> = (
data: ScratchpadEvents[K]
) => void;Error Handling
agent-scratchpad exports three error classes, all extending a common base.
ScratchpadError
Base class for all scratchpad errors. Extends Error with a code property.
import { ScratchpadError } from 'agent-scratchpad';
try {
pad.restore(badSnapshot);
} catch (err) {
if (err instanceof ScratchpadError) {
console.error(err.code); // e.g. 'SCRATCHPAD_VERSION_ERROR'
console.error(err.message); // human-readable description
}
}| Property | Type | Description |
|---|---|---|
| code | string | Machine-readable error code. |
| message | string | Human-readable error description. |
| name | string | Always 'ScratchpadError'. |
ScratchpadConfigError
Thrown when invalid configuration is provided to createScratchpad().
- Code:
SCRATCHPAD_CONFIG_ERROR
ScratchpadVersionError
Thrown when restore() encounters a snapshot with an unsupported version number.
- Code:
SCRATCHPAD_VERSION_ERROR - Additional property:
version(number) -- The unsupported version that was encountered.
import { ScratchpadVersionError } from 'agent-scratchpad';
try {
pad.restore(snap);
} catch (err) {
if (err instanceof ScratchpadVersionError) {
console.error(`Unsupported version: ${err.version}`);
}
}Advanced Usage
Agent Working Memory in a ReAct Loop
import { createScratchpad } from 'agent-scratchpad';
const pad = createScratchpad({ defaultTtl: 300_000 }); // 5-minute default
// Step 1: Store tool output
pad.set('search:result', apiResponse, { tags: ['tool-result', 'search'] });
// Step 2: Extract and store entities
pad.set('entities:user', { name: 'Alice', id: 42 }, { tags: ['entity'] });
pad.set('entities:order', { orderId: '#12345' }, { tags: ['entity'] });
// Step 3: Inject scratchpad into prompt
const agentContext = pad.toContext({
format: 'markdown',
header: '## Agent Working Memory',
filterTags: ['entity'],
});
// Produces:
// ## Agent Working Memory
// ## entities:user
// [object Object]
// ...Namespace Isolation for Multi-Agent Systems
const pad = createScratchpad();
const agent1 = pad.namespace('agent1');
const agent2 = pad.namespace('agent2');
agent1.set('plan', 'Research the topic');
agent2.set('plan', 'Draft the response');
// Each agent sees only its own entries
agent1.keys(); // ['plan']
agent2.keys(); // ['plan']
// Parent sees all entries with prefixed keys
pad.keys(); // ['agent1:plan', 'agent2:plan']
// Clear one agent without affecting the other
agent1.clear();
agent2.keys(); // ['plan'] -- unaffectedSliding TTL for Session-Like Data
const pad = createScratchpad();
// Session token stays alive as long as the agent keeps accessing it
pad.set('session', { token: 'abc123' }, { ttl: 30_000, slidingTtl: true });
// Each access resets the 30-second expiration window
pad.get('session'); // resets timer
pad.get('session'); // resets timer again
// If 30 seconds pass without access, the entry expiresBackground Sweep with Event Logging
const pad = createScratchpad({ sweepIntervalMs: 10_000 });
pad.on('expire', ({ key, entry }) => {
console.log(`Expired: ${key} (created ${new Date(entry.createdAt).toISOString()})`);
});
pad.set('temp', 'data', { ttl: 15_000 });
// The sweep timer runs every 10 seconds and removes expired entries.
// The expire event fires for each entry removed by the sweep.
// Stop the sweep timer when done
await pad.destroy();Persistence with a File-Based Adapter
import { createScratchpad, PersistenceAdapter, ScratchpadSnapshot } from 'agent-scratchpad';
import fs from 'fs/promises';
const fileAdapter: PersistenceAdapter = {
async load() {
try {
const data = await fs.readFile('scratchpad.json', 'utf8');
return JSON.parse(data) as ScratchpadSnapshot;
} catch {
return null;
}
},
async save(snap) {
await fs.writeFile('scratchpad.json', JSON.stringify(snap, null, 2));
},
};
const pad = createScratchpad({ persistence: fileAdapter });
// Restore previous state on startup
await pad.load();
// Work with the scratchpad
pad.set('progress', 'step-3');
// Persist state before shutdown
await pad.save();Snapshot-Based Backtracking
const pad = createScratchpad();
pad.set('approach', 'strategy-A');
pad.set('findings', ['result-1']);
// Save state before trying something risky
const checkpoint = pad.snapshot();
pad.set('approach', 'strategy-B');
pad.set('findings', ['result-1', 'result-2-failed']);
// Strategy B failed -- roll back
pad.restore(checkpoint);
pad.get('approach'); // 'strategy-A'Token-Budget-Aware Context Rendering
import { createScratchpad } from 'agent-scratchpad';
const pad = createScratchpad();
pad.set('summary', 'A long summary of findings...');
pad.set('details', 'Extensive details that may not fit...');
// Use a custom token counter (e.g., tiktoken)
const context = pad.toContext({
format: 'kv',
maxTokens: 500,
tokenCounter: (text) => Math.ceil(text.length / 4), // rough estimate
header: '## Working Memory',
});Deterministic Testing with Custom Time
import { createScratchpad } from 'agent-scratchpad';
let now = 0;
const pad = createScratchpad({ now: () => now });
pad.set('key', 'value', { ttl: 100 });
now = 50;
pad.get('key'); // 'value' -- still alive
now = 100;
pad.get('key'); // undefined -- expired exactly at 100msTypeScript
agent-scratchpad is written in TypeScript and ships with full type declarations. All exported functions, interfaces, and types are available for import:
import {
createScratchpad,
fromSnapshot,
toContext,
isExpired,
expiresAt,
ScratchpadError,
ScratchpadConfigError,
ScratchpadVersionError,
} from 'agent-scratchpad';
import type {
Scratchpad,
ScratchpadEntry,
ScratchpadOptions,
ScratchpadSnapshot,
ScratchpadStats,
ToContextOptions,
EntryOptions,
ScratchpadEventName,
ScratchpadEventHandler,
ScratchpadEvents,
PersistenceAdapter,
} from 'agent-scratchpad';Generic type parameters on get<T>() and set<T>() provide type-safe value access without casts:
interface User {
id: number;
name: string;
}
pad.set<User>('user', { id: 1, name: 'Alice' });
const user = pad.get<User>('user');
// user is User | undefinedLicense
MIT
