jatin.blog ~ $
$ cat ai-engineering/working-memory-scratchpads.md

Working Memory: Scratchpads, Blackboards, and Agent Notebooks

Agent working memory using scratchpads, typed state, notebooks, and multi-agent blackboards.

Jatin Bansal@blog:~/ai-engineering$ open working-memory-scratchpads

A conversation buffer does not reliably represent task state. A long-running coding agent may retain recent messages yet lose the list of migrated files, unresolved questions, and rejected approaches. Working memory gives the harness a structured task state that can be updated and re-injected independently of chat history.

The task-state contract

Working memory is structured task state that the harness serializes into the prompt. It may use typed fields, a key-value store, a graph node, or a file. The agent updates it through an explicit tool or structured directive, and it survives eviction of the messages that produced it. At task completion, the harness can discard it or promote selected items into episodic or semantic memory.

The short-term conversation buffer remains message-shaped and is usually evicted by age or token pressure. Long-term episodic storage persists across tasks, while model activations disappear after each forward pass. Working memory occupies the middle: an editable, auditable notebook for the current task.

Chain-of-thought scratchpads

The simplest working-memory substrate. The model writes structured prose into a thought channel (the <thinking> block in extended-thinking mode, the Thought: line in classic ReAct, or an unstructured “scratch” assistant turn) and the harness either keeps that text in the buffer or extracts it into a side channel for the next turn.

When this is right. Short-horizon tasks, single-shot reasoning chains, anywhere the cost of structured state would exceed the cost of letting the model recompute. The original ReAct paper (Yao et al., 2022) uses exactly this; the thought/action/observation sequence is the scratchpad, replayed in full to the model on every step.

When this is wrong. Anything that runs past 20 turns. The scratchpad grows linearly in the chat log, falls into the eviction-middle of the short-term memory buffer, and the lost-in-the-middle effect kicks in. By turn 60 the model has the scratchpad textually but isn’t attending to most of it. The structured-state substrate below is the upgrade.

Anthropic’s “think” tool is the modern incarnation. Anthropic’s think-tool post (March 2025) describes it as “a designated space to stop and think about whether it has all the information it needs to move forward” during response generation, not before. The think tool is a chain-of-thought scratchpad with a tool boundary; the model invokes it explicitly, the contents land in a tool_use block, and the harness can choose to keep or evict it on the next turn. τ-Bench airline-domain numbers in the post show a 54% relative improvement when paired with optimized prompting. Treat it as a structured CoT scratchpad with explicit eviction control rather than something exotic.

Typed state objects

The harness exposes a typed state schema. The agent (model + tools) reads and writes named fields. The harness re-serializes the schema into the prompt on every model call, optionally elided to the fields the current node needs.

This is the default in LangGraph, OpenAI’s Agents SDK RunContextWrapper, and Letta’s core memory blocks.

Python: LangGraph state with overwrite vs append channels

python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
# pip install "langgraph>=0.2" "langchain-anthropic>=0.2" "anthropic>=0.40"
import operator
from typing import Annotated, TypedDict
from langchain_core.messages import AnyMessage, HumanMessage, SystemMessage
from langchain_anthropic import ChatAnthropic
from langgraph.graph import StateGraph, START, END

# Two channels with different reducers — the dataflow-graph idea made concrete.
# `messages` accumulates (operator.add appends list-to-list).
# `plan`, `completed`, `notes` overwrite on each write; this is the working-memory tier
# the harness keeps explicit, separate from the chat buffer.
class AgentState(TypedDict):
    messages: Annotated[list[AnyMessage], operator.add]
    plan: list[str]              # overwrite — the model rewrites the plan as it learns
    completed: list[str]         # overwrite — the model emits the full list each time
    notes: Annotated[list[str], operator.add]  # append — facts pinned across turns

llm = ChatAnthropic(model="claude-opus-4-7", max_tokens=2048)

def working_memory_prompt(state: AgentState) -> SystemMessage:
    """Render the working-memory block that gets injected on every model call.
    This is the bit that distinguishes working memory from short-term memory: it's
    a deliberate, structured slab the harness assembles, not whatever the model
    happened to say in the last 20 turns."""
    lines = ["## Working memory (your private scratchpad — re-injected every turn)"]
    if state.get("plan"):
        lines.append("### Current plan")
        lines.extend(f"- {step}" for step in state["plan"])
    if state.get("completed"):
        lines.append("### Completed steps")
        lines.extend(f"- {step}" for step in state["completed"])
    if state.get("notes"):
        lines.append("### Pinned notes")
        lines.extend(f"- {note}" for note in state["notes"])
    return SystemMessage(content="\n".join(lines))

def model_node(state: AgentState) -> dict:
    """Standard LLM step. The working-memory block is prepended to the chat
    history; the model can update working memory by emitting a structured
    directive in its next tool call (omitted here for brevity)."""
    wm_block = working_memory_prompt(state)
    response = llm.invoke([wm_block] + state["messages"])
    return {"messages": [response]}

graph = StateGraph(AgentState)
graph.add_node("model", model_node)
graph.add_edge(START, "model")
graph.add_edge("model", END)
compiled = graph.compile()

# Initial state — the working-memory fields are explicit, separate from `messages`.
result = compiled.invoke({
    "messages": [HumanMessage("Refactor utils.py to remove the global state.")],
    "plan": [
        "Identify every reader of the global.",
        "Introduce a Config dataclass.",
        "Thread Config through call sites.",
        "Delete the global.",
    ],
    "completed": [],
    "notes": ["The global is named CONFIG and is set in utils.py:14."],
})

The point is the separation. messages is the conversation buffer (short-term memory). plan, completed, notes are working memory; typed, explicit, with per-field reducers chosen for the access pattern. On turn 90 the chat buffer can be aggressively trimmed; the working-memory block is re-injected in full and the agent never loses the plan. This is also how you avoid the token-accumulation bug in recursive agent loops: an overwrite reducer on plan keeps the field bounded even after 200 revisions, where an append reducer would grow linearly.

TypeScript: OpenAI Agents SDK with a typed context

The OpenAI Agents SDK (TypeScript) exposes the same idea through a typed context object accessible from tools and lifecycle hooks.

typescript
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
// pnpm add @openai/agents zod
import { Agent, Runner, tool } from "@openai/agents";
import { z } from "zod";

// The working-memory shape lives in TypeScript, not in the prompt.
interface WorkingMemory {
  plan: string[];
  completed: string[];
  notes: string[];
}

const initialMemory: WorkingMemory = {
  plan: [
    "Identify every reader of the global.",
    "Introduce a Config dataclass.",
    "Thread Config through call sites.",
    "Delete the global.",
  ],
  completed: [],
  notes: ["The global is named CONFIG and is set in utils.ts:14."],
};

// Tools receive the typed context and can mutate working memory directly.
const markCompleted = tool({
  name: "mark_completed",
  description: "Mark a plan step as done; moves it from plan to completed.",
  parameters: z.object({ step: z.string() }),
  async execute({ step }, runContext) {
    const mem = runContext.context as WorkingMemory;
    mem.plan = mem.plan.filter((s) => s !== step);
    mem.completed.push(step);
    return `Marked completed: ${step}`;
  },
});

const addNote = tool({
  name: "add_note",
  description: "Pin a fact in working memory.",
  parameters: z.object({ note: z.string() }),
  async execute({ note }, runContext) {
    (runContext.context as WorkingMemory).notes.push(note);
    return `Pinned: ${note}`;
  },
});

// The agent's instructions are dynamic: the working-memory block is rendered
// from the typed context every turn. This is the key move — the prompt always
// shows the latest working memory, no matter how trimmed the conversation
// buffer is.
const refactorAgent = new Agent<WorkingMemory>({
  name: "refactor-agent",
  model: "gpt-5",
  tools: [markCompleted, addNote],
  instructions: (runContext, agent) => {
    const mem = runContext.context;
    return [
      "You refactor TypeScript codebases. Use the working-memory tools to track progress.",
      "",
      "## Working memory (your private scratchpad)",
      "### Plan",
      ...mem.plan.map((s) => `- ${s}`),
      "### Completed",
      ...(mem.completed.length ? mem.completed.map((s) => `- ${s}`) : ["- (none yet)"]),
      "### Pinned notes",
      ...mem.notes.map((n) => `- ${n}`),
    ].join("\n");
  },
});

const result = await Runner.run(refactorAgent, "Continue the refactor.", {
  context: initialMemory,
});

The instructions function is the working-memory renderer. The typed WorkingMemory is the actual state. The two are coupled by the agent’s contract: tools mutate the typed state, the instructions render it back into the prompt on every turn. This is the typed-dataflow-graph substrate in OpenAI-Agents-SDK terms. The official cookbook Context Engineering for Personalization is the production-grade reference for the pattern.

External notebooks

The model invokes a tool to write to a file or key-value store outside the prompt. Reads happen via the same tool on demand. The notebook persists across turns and, with a backing store, across sessions; at which point it starts to overlap with long-term memory (a related article in the subtree).

Anthropic’s memory tool, released as the memory_20250818 server-side tool type, is the canonical reference. The tool exposes view, create, str_replace, insert, delete, and rename commands operating on a client-side /memories directory. The harness implements the file operations locally; the model invokes them via tool calls. The auto-injected system-prompt instruction tells the model to always view the memory directory before doing anything else and to record progress as it goes; explicitly framing the tool as a working-memory substrate that survives both context-window eviction and full-session resets.

python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
# pip install "anthropic>=0.40"
# Minimal notebook substrate using Anthropic's memory tool.
from pathlib import Path
import anthropic

MEMORY_ROOT = Path("./agent_memory").resolve()
MEMORY_ROOT.mkdir(exist_ok=True)

def handle_memory_tool(tool_input: dict) -> str:
    """Client-side handler for the memory_20250818 tool. Path-traversal-safe."""
    cmd = tool_input["command"]
    # Validate every path stays under MEMORY_ROOT — the Anthropic docs flag
    # this as the single most important safeguard.
    def safe_path(raw: str) -> Path:
        p = (MEMORY_ROOT / raw.lstrip("/")).resolve()
        if not p.is_relative_to(MEMORY_ROOT):
            raise ValueError(f"Path escapes memory root: {raw}")
        return p

    if cmd == "view":
        p = safe_path(tool_input["path"])
        if p.is_dir():
            entries = "\n".join(f"{f.stat().st_size}\t/{f.relative_to(MEMORY_ROOT)}"
                                for f in sorted(p.iterdir()))
            return f"Directory listing for {tool_input['path']}:\n{entries}"
        return p.read_text()
    if cmd == "create":
        safe_path(tool_input["path"]).write_text(tool_input["file_text"])
        return f"File created at {tool_input['path']}"
    if cmd == "str_replace":
        p = safe_path(tool_input["path"])
        text = p.read_text()
        if tool_input["old_str"] not in text:
            return f"No replacement: old_str not found in {tool_input['path']}"
        p.write_text(text.replace(tool_input["old_str"], tool_input["new_str"], 1))
        return "The memory file has been edited."
    # Implement insert/delete/rename similarly in production.
    raise ValueError(f"Unknown memory command: {cmd}")

client = anthropic.Anthropic()

# The tool registration is one line; Anthropic injects the working-memory
# protocol into the system prompt automatically.
def run_agent_turn(messages: list[dict]) -> dict:
    return client.messages.create(
        model="claude-opus-4-7",
        max_tokens=2048,
        messages=messages,
        tools=[{"type": "memory_20250818", "name": "memory"}],
    )

# The harness loop dispatches memory tool calls back to the local handler.
# Loop omitted for brevity — see anthropic-sdk-python/examples/memory/basic.py
# for a full runnable version.

The trade with the notebook substrate is explicitness for latency. Every read costs a tool round-trip. The typed-state-object substrate is free to read (it’s in the prompt every turn); the notebook is paid-per-read. The right call depends on working set size: if the working memory fits in a 2KB block, render it every turn and skip the tool; if it grows past tens of KB and most of it is irrelevant to most steps, the notebook lets the agent page in only what it needs; the same JIT-vs-AOT context engineering decision, applied at the working-memory layer instead of the retrieval layer.

Letta’s core memory blocks implement the same idea with a different boundary: a fixed set of small, addressable working-memory blocks (persona, human, scratchpad) that the agent edits through core_memory_append and core_memory_replace tools, with the contents always visible in the prompt. Same pattern, different ergonomics; Letta’s blocks are always in-context; Anthropic’s /memories directory is in-context only on demand.

Shared blackboards

When working memory is shared across multiple agents, the substrate becomes a blackboard. The 1971 Hearsay-II architecture is the canonical reference: a global typed store, multiple knowledge sources reading and writing concurrently, a scheduler activating the next knowledge source based on board state.

The 2025 LLM blackboard papers replay the same pattern with LLMs as the knowledge sources: a central controller posts the problem onto a shared board, sub-agents inspect the board and self-nominate based on what’s relevant to their role, results land back on the board, the loop continues until the controller decides consensus has been reached. The reported gains over baselines come from two places; agents self-selecting based on board state (no central router), and shared visibility cutting redundant work across agents.

The implementation surface is what you’d expect from a distributed shared-memory system: every read needs a stable snapshot; every write needs conflict resolution (last-writer-wins is the floor; CRDT-style merges are the ceiling); the split-brain failure mode from the multi-agent orchestration article is the same disease here, applied to the working-memory layer instead of the messaging layer. The deeper treatment lands in the multi-agent shared memory article; the four sharing patterns (supervisor-mediated, shared-block, cross-thread store, blackboard) and the four consistency questions every one of them has to answer; for now the takeaway is that the blackboard is the multi-writer specialization of the typed-state-object substrate, with concurrency control bolted on.

Further reading