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.
npm install agent-scratchpadimport{createScratchpad}from'agent-scratchpad';constpad=createScratchpad();// Store intermediate resultspad.set('step','analyze');pad.set('user',{id: 42,name: 'Alice'});console.log(pad.get('step'));// 'analyze'console.log(pad.has('user'));// trueconsole.log(pad.keys());// ['step', 'user']// Render contents for an LLM promptconstcontext=pad.toContext({format: 'markdown'});- 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
Creates a new Scratchpad instance.
import{createScratchpad}from'agent-scratchpad';constpad=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
Creates a new Scratchpad pre-populated from a previously captured snapshot.
import{fromSnapshot}from'agent-scratchpad';constpad=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
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
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).
constuser=pad.get<{id: number;name: string}>('user');Parameters:
key(string) -- The entry key.
Returns:T | undefined
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
Removes an entry by key. Fires a delete event if the entry existed.
constremoved=pad.delete('staleData');// true if it existedParameters:
key(string) -- The entry key.
Returns:boolean -- true if the entry existed and was removed, false otherwise.
Removes all entries from the scratchpad. Fires a clear event with the count of removed entries.
pad.clear();Returns:void
Returns an array of all non-expired keys. Expired entries encountered during iteration are excluded.
constallKeys=pad.keys();// ['step', 'user', 'result']Returns:string[]
Returns an array of [key, ScratchpadEntry] tuples for all non-expired entries.
for(const[key,entry]ofpad.entries()){console.log(key,entry.value,entry.tags);}Returns:[string, ScratchpadEntry][]
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']});constgeoEntries=pad.findByTag('geo');// both entriesParameters:
tag(string) -- The tag to search for.
Returns:ScratchpadEntry[]
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.
constmemory=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 prefixesconstdeep=pad.namespace('a').namespace('b');deep.set('key','val');pad.get('a:b:key');// 'val'// Namespace-scoped operationsmemory.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.
Captures the full scratchpad state at the current point in time. The returned snapshot is a plain object suitable for serialization.
constsnap=pad.snapshot();// { entries: { ... }, timestamp: 1710000000000, version: 1 }Returns:ScratchpadSnapshot
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.
Alias for snapshot(). Returns the same ScratchpadSnapshot structure.
constdata=pad.serialize();Returns:ScratchpadSnapshot
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
Returns aggregate statistics about the scratchpad's current state.
constst=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. |
Registers an event handler. Returns an unsubscribe function.
constunsub=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 listeningunsub();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.
Persists the current scratchpad state using the configured PersistenceAdapter. No-op if no adapter was provided.
awaitpad.save();Returns:Promise<void>
Loads scratchpad state from the configured PersistenceAdapter and restores it. No-op if no adapter was provided or the adapter returns null.
awaitpad.load();Returns:Promise<void>
Cleans up resources. Stops the background sweep timer if one is running.
awaitpad.destroy();Returns:Promise<void>
Determines whether a scratchpad entry has expired based on its TTL configuration.
import{isExpired}from'agent-scratchpad';constexpired=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.
Calculates the absolute expiration timestamp for an entry.
import{expiresAt}from'agent-scratchpad';constexpiry=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.
interfaceScratchpadEntry<T=unknown>{key: string;value: T;createdAt: number;// Unix ms when first createdupdatedAt: number;// Unix ms when value last updatedaccessedAt: number;// Unix ms when last read via get()ttl: number|null;// TTL in ms, null = no expirationslidingTtl: boolean;// true = TTL resets on accesstags: string[];// string labels for categorization}interfaceEntryOptions{ttl?: number|null;slidingTtl?: boolean;tags?: string[];}interfaceScratchpadOptions{defaultTtl?: number|null;defaultSlidingTtl?: boolean;sweepIntervalMs?: number|null;now?: ()=>number;persistence?: PersistenceAdapter;}interfaceScratchpadSnapshot{entries: Record<string,ScratchpadEntry>;timestamp: number;version: 1;}interfaceScratchpadStats{size: number;rawSize: number;namespaceCount: number;namespaces: string[];entriesWithTtl: number;tagCounts: Record<string,number>;oldestEntryAt: number|null;newestEntryAt: number|null;}interfaceToContextOptions{format?: 'markdown'|'xml'|'json'|'kv';filterTags?: string[];filterNamespace?: string;maxTokens?: number;tokenCounter?: (text: string)=>number;includeMetadata?: boolean;header?: string;}interfacePersistenceAdapter{load(): Promise<ScratchpadSnapshot|null>;save(snap: ScratchpadSnapshot): Promise<void>;}interfaceScratchpadEvents{set: {key: string;entry: ScratchpadEntry;isUpdate: boolean};delete: {key: string;entry: ScratchpadEntry};expire: {key: string;entry: ScratchpadEntry};clear: {count: number};}typeScratchpadEventName='set'|'delete'|'expire'|'clear';typeScratchpadEventHandler<KextendsScratchpadEventName>=(data: ScratchpadEvents[K])=>void;agent-scratchpad exports three error classes, all extending a common base.
Base class for all scratchpad errors. Extends Error with a code property.
import{ScratchpadError}from'agent-scratchpad';try{pad.restore(badSnapshot);}catch(err){if(errinstanceofScratchpadError){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'. |
Thrown when invalid configuration is provided to createScratchpad().
- Code:
SCRATCHPAD_CONFIG_ERROR
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(errinstanceofScratchpadVersionError){console.error(`Unsupported version: ${err.version}`);}}import{createScratchpad}from'agent-scratchpad';constpad=createScratchpad({defaultTtl: 300_000});// 5-minute default// Step 1: Store tool outputpad.set('search:result',apiResponse,{tags: ['tool-result','search']});// Step 2: Extract and store entitiespad.set('entities:user',{name: 'Alice',id: 42},{tags: ['entity']});pad.set('entities:order',{orderId: '#12345'},{tags: ['entity']});// Step 3: Inject scratchpad into promptconstagentContext=pad.toContext({format: 'markdown',header: '## Agent Working Memory',filterTags: ['entity'],});// Produces:// ## Agent Working Memory// ## entities:user// [object Object]// ...constpad=createScratchpad();constagent1=pad.namespace('agent1');constagent2=pad.namespace('agent2');agent1.set('plan','Research the topic');agent2.set('plan','Draft the response');// Each agent sees only its own entriesagent1.keys();// ['plan']agent2.keys();// ['plan']// Parent sees all entries with prefixed keyspad.keys();// ['agent1:plan', 'agent2:plan']// Clear one agent without affecting the otheragent1.clear();agent2.keys();// ['plan'] -- unaffectedconstpad=createScratchpad();// Session token stays alive as long as the agent keeps accessing itpad.set('session',{token: 'abc123'},{ttl: 30_000,slidingTtl: true});// Each access resets the 30-second expiration windowpad.get('session');// resets timerpad.get('session');// resets timer again// If 30 seconds pass without access, the entry expiresconstpad=createScratchpad({sweepIntervalMs: 10_000});pad.on('expire',({ key, entry })=>{console.log(`Expired: ${key} (created ${newDate(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 doneawaitpad.destroy();import{createScratchpad,PersistenceAdapter,ScratchpadSnapshot}from'agent-scratchpad';importfsfrom'fs/promises';constfileAdapter: PersistenceAdapter={asyncload(){try{constdata=awaitfs.readFile('scratchpad.json','utf8');returnJSON.parse(data)asScratchpadSnapshot;}catch{returnnull;}},asyncsave(snap){awaitfs.writeFile('scratchpad.json',JSON.stringify(snap,null,2));},};constpad=createScratchpad({persistence: fileAdapter});// Restore previous state on startupawaitpad.load();// Work with the scratchpadpad.set('progress','step-3');// Persist state before shutdownawaitpad.save();constpad=createScratchpad();pad.set('approach','strategy-A');pad.set('findings',['result-1']);// Save state before trying something riskyconstcheckpoint=pad.snapshot();pad.set('approach','strategy-B');pad.set('findings',['result-1','result-2-failed']);// Strategy B failed -- roll backpad.restore(checkpoint);pad.get('approach');// 'strategy-A'import{createScratchpad}from'agent-scratchpad';constpad=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)constcontext=pad.toContext({format: 'kv',maxTokens: 500,tokenCounter: (text)=>Math.ceil(text.length/4),// rough estimateheader: '## Working Memory',});import{createScratchpad}from'agent-scratchpad';letnow=0;constpad=createScratchpad({now: ()=>now});pad.set('key','value',{ttl: 100});now=50;pad.get('key');// 'value' -- still alivenow=100;pad.get('key');// undefined -- expired exactly at 100msagent-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';importtype{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:
interfaceUser{id: number;name: string;}pad.set<User>('user',{id: 1,name: 'Alice'});constuser=pad.get<User>('user');// user is User | undefinedMIT