Agent memory

Give an AI character notes that outlive its context window. Set the `memory` option on an agent and it gets three tools — `open_memory`, `close_memory`, `delete_memory` — plus a store of named notes it writes itself. An *open* memory is rendered in full at the head of its context and is kept current by the compactor as older conversation is summarized away; a *closed* one is listed by description only, until the agent opens it. Covers what a memory is, who may write it, exactly what the agent sees, the tools and their errors, the `maxOpenTokens` budget and its definition-time rule, the 1 MB store cap, what it costs, and how to prompt an agent into using it well.

An agent's conversation history is finite. Once it passes its token budget the platform compacts it: the oldest exchanges are replaced by a summary, and the details in them are gone for good. That is fine for a short scene and lossy for a long game — three hours in, the character has forgotten the player's sister's name.

Memory is the fix. A memory is a named note the agent writes for itself and keeps across every compaction. The agent decides what is worth writing; the platform makes sure it survives.

Turning it on

Memory is a per-agent capability. Setting the field is what grants the agent its tools — every option has a default, so memory: {} is the whole setup:

ts
io.agents.dm = new io.Agent('dm', {
  systemPrompt: 'You are the dungeon master. Keep the story straight.',
  memory: {},
})

| Option | Default | What it does | | --- | --- | --- | | maxOpenTokens | 4000 | Budget for the content of the memories the agent holds open at once. open_memory refuses to go over it and tells the agent to close something first. |

maxOpenTokens is a guideline, not a platform limit — raise it freely. Its one hard rule is that it must stay below half of the agent's compaction budget, so open memories can never crowd out the recent history they sit beside. That is checked when the game is defined, and the default rules out a small maxTokens:

ts
// Throws at definition time: 4000 is not below 8000 / 2.
new io.Agent('dm', { memory: {}, compaction: { type: 'summarize', maxTokens: 8000 } })

// Either give the agent more room…
new io.Agent('dm', { memory: {}, compaction: { type: 'summarize', maxTokens: 16000 } })
// …or take less of it for memories.
new io.Agent('dm', { memory: { maxOpenTokens: 1000 }, compaction: { type: 'summarize', maxTokens: 8000 } })

Memory works with either compaction strategy. Under the default summarize the compactor also maintains open memories (below). Under sliding-window — where old messages are simply dropped, with no summary — memories still work, they are just written by the agent alone.

No agent keeps more than about 200 blocks of verbatim history, no matter how high maxTokens is set: once an agent's oldest verbatim message has aged a whole 200-block window, the platform writes a forced checkpoint summarizing everything before the current block. (About, not exactly — a cancelled turn ages the history without running the checkpoint, so it can run a little past the window.) A bigger maxTokens buys a bigger window in tokens, not unlimited depth in blocks — this is what keeps storage and session loads predictable. A forced checkpoint with nothing new to summarize (or one on a sliding-window agent, which never summarizes) makes no LLM call; it just carries the previous checkpoint forward unchanged.

Constructing an agent again — new io.Agent(name, ...) for a name that already exists — updates its config only; history, checkpoint, and memories are untouched. It's safe to construct an agent at the top of every event handler rather than guarding on whether it already exists.

What a memory is

name         a short handle the agent chooses, e.g. `quest`
description  one line saying what it holds and when to open it
content      the body — whatever the agent wants to keep
open/closed  one bit, controlled by the agent

The open bit is the whole model:

  • open — the content is rendered in full at the head of the agent's context, and the compactor keeps it current as it folds the conversation away.
  • closed — only the description is listed. Nothing writes it, and the agent cannot read it, until it opens it.

That is the non-blind-write rule: nothing ever rewrites a memory it cannot see. It is why close_memory on a closed memory is an error rather than a silent overwrite, and why the compactor can only touch memories that were open.

Who writes what

| | Creates | Opens / closes | Rewrites content | Deletes | | --- | --- | --- | --- | --- | | The agent (its tools) | yes | yes | yes | yes | | The compactor | no | no | open memories only | no | | Game code | — | — | — | — |

What the agent sees

Everything the agent knows that is no longer in its verbatim history arrives as one platform-written block at the head of its context:

[Context — written by the platform, not said by anyone. Play continues from the messages after this one.]

## Summary of earlier conversation

The player (Mara) reached the drowned chapel and bargained with the ferryman…

## Open memories — current as of this point

### quest — What the player is trying to do right now, and why
Retrieve the silver key from the drowned chapel. Mara believes it opens her
sister's house in Ashmere.

## Closed memories — call open_memory to read one

- npcs — Everyone Mara has met and what they think of her
- inventory — What Mara is carrying

[End of context]

That block is the agent's checkpoint: the state as of the last compaction. Between compactions it does not change. A memory the agent opens or writes mid-conversation comes back to it as the tool's result — that is how it reads what it just opened — and joins this block from the next compaction on.

The tools

open_memory({ name }) — reads a memory and marks it open. The result is its full content, right there in the tool result. A name that does not exist is an error listing what does; a name already open is simply reported as such; going over maxOpenTokens is an error naming the memories to close first.

close_memory({ name, description, content }) — writes a memory and closes it. This is the only way anything is saved. It is create-or-update:

  • a name that does not exist yet is created, and needs both description and content;
  • a name the agent has open is updated with whichever fields it passes;
  • a name that is closed is an error — open it first.

delete_memory({ name }) — removes a memory permanently. Requires it to be open, for the same reason: nothing is destroyed unseen.

Every one of those calls is recorded in the agent's history alongside its result, so the studio's Agents surface shows exactly what the character did and why. The current store is in the Memories surface next to it.

Memory tools are only offered on a normal send. A transient: true send leaves no trace in the agent's history, so it never gets them.

Limits

maxOpenTokens above bounds what the agent holds open — that one protects the agent's own context window and is yours to tune.

The one hard limit protects the platform: an agent's whole store is capped at ~1 MB (it rides every compaction record and every session load). That is hundreds of pages of prose. A close_memory that would exceed it is refused with the actual numbers, and the agent can delete something it no longer needs.

What it costs

A small flat credit fee per memory tool call — including calls that come back an error, since the model spent the round either way. It is itemised in the turn's recorded cost breakdown as a "Memory access" line, beside image generation and the rest.

What the compactor writes back into open memories is billed with the compaction call, not the flat per-call fee above: it rides the tokens of a summarization you're already paying for, rather than costing extra.

Prompting for good memory

The tool descriptions teach the agent the mechanism. The system prompt is where you tell it what to use the mechanism for — be specific, and name the memories:

ts
new io.Agent('dm', {
  memory: {},
  systemPrompt: `You are the dungeon master…

  Keep a memory named \`quest\` open at all times: what the player is trying to do right
  now and why. Update it whenever their goal changes.

  Keep \`npcs\` (everyone they have met and how that meeting went) and \`world\` (places,
  factions, rules you have established) as closed memories. Open one when the scene turns
  to it, write what changed, and close it again.`,
})

Rules of thumb:

  • Two or three memories, not twenty. One always-open "what's happening now" memory plus a couple of filed reference notes covers most games.
  • Memories are for what the character knows; state is for what the game runs on. Anything the frontend renders, the reducer branches on, or you want to be exactly right (hit points, inventory counts, the secret number) belongs in game state, where it is authoritative and free. Memories hold the soft, narrative knowledge no schema wants — the promise the player made, why this NPC is angry.
  • Don't mirror state into a memory. Put the current values in the message you send instead; that costs nothing and can never go stale.
  • Give the agent a reason to close things. An agent that opens everything and never closes hits its budget and stalls at the ceiling; a line telling it when a memory's scene is over keeps the window healthy.

See also

  • multi-character-scenes — several agents in one scene; each gets its own store.
  • structured-output — typed data out of an agent, which is often the right alternative when the thing you want back belongs in game state.