---
title: "Cookbook: Simple memory system"
type: cookbook
tags: [cookbook, memory, harness, noul, choice, state]
created: 2026-10-12
updated: 2026-10-15
confidence: high
sources:
  - raw/docs/cookbooks__basic_memory.md
jev_version: "jev-1.14.0"
sdk_python: "0.7.5"   # cookbooks install cooksafe 0.2.0, which requires typesafe-sdk>=0.7.0,<0.8.0
summary: "Give Jev memory across calls: a Noul gate after each call saves requests that state a lasting customer fact; a wrapper adds saved ones to later state. Routing: 6/6 with memory vs 2/6."
---

# Cookbook: Simple memory system

> **TL;DR** Jev sees only the `state` you send, so memory lives in your code. After each call, a second request asks a `Noul` "gate" whether the original request states a lasting fact about the customer; at P(yes) ≥ 0.5 the request and Jev's answer are saved under the customer's ID, and a wrapper, `ask_with_memory()`, adds that customer's saved memories to every later `state`. In TypeSafe's support-routing example, all six later tickets reach the expected team with memory, two of six without. Two sequential requests per call; memory only grows; the 0.5 is a starting point to test on your own labels.

## Goal

Carry facts from earlier requests into later ones. A customer said in August that they self-host on Windows Server; in September they write "Dashboards have been really slow since this morning". Each Jev request sees only its own `state`, so plain Jev routes that ticket to the cloud team. The cookbook calls its wrapper "a basic agent harness": the code around the model that gives it memory, here a Python dict with one list per customer ID.

When to use it: a repeated decision about the same entity (customer, account, device) where an earlier message changes how later ones should be read. When not to: facts you already hold in a database (put them in `state` directly), or anything where a wrong saved fact costs more than a missing one (see Gotchas).

## Inputs / state shape

Two customers, written for the cookbook: C-1042 (Harbor Logistics, self-hosted edition on Windows Server, Enterprise contract) and C-2210 (Fernwood Studio, cloud edition, self-serve Team plan). Five earlier tickets, three of which state a lasting fact (an edition, a plan, a contract) and two of which do not (typing on a phone at the airport; a public holiday). Then three later messages per customer that never mention the setup: slow dashboards, a failing upgrade to version 5.2, 20 more seats. Expected teams were labelled by hand from the earlier tickets.

Each ticket's state is a dict with `customer` (`id`, `company`) and `ticket` (`id`, `received_on`, `subject`, `messages` as a list of `{"from", "text"}`). The wrapper adds saved memories under the key `earlier_tickets_from_this_customer`. A saved memory is `{"request": <state before memories were added>, "jev_answered": {<question instructions>: <answer text>}}`, so old memories are never nested inside new ones.

Numbers below came from `jev-1.13.0` on 2026-09-28.

## Questions asked

**Routing** (`ROUTE`), one `Choice`:

| key | `instructions` | `criteria` |
|---|---|---|
| `team` | `Which support team should handle \`ticket\`?` | `cloud_ops`: "Cloud team: problems with the hosted cloud edition, run by us"; `selfhosted_linux`: "Self-hosted team, Linux: installs, upgrades and performance of the self-hosted edition on Linux servers"; `selfhosted_windows`: same for Windows Server; `billing`: "Billing: invoices, payments, seats and plan changes for self-serve plans"; `enterprise_desk`: "Enterprise desk: seats, contracts and account changes for customers on an Enterprise contract (their technical problems go to the technical teams)"; `general`: "General product questions: features, how-to, settings"; `none_suitable`: "None of these teams fits the ticket" |

The `none_suitable` option gives Jev an answer when no team fits; a ticket with too little context can land there.

**Gate** (`GATE`), one `Noul`, verbatim:

```python
GATE = {
    "remember": Noul(
        instructions=(
            "In `request.ticket`, does the thread state a lasting fact about the customer that "
            "should change how their future, unrelated tickets are handled? Count what the "
            "customer or our support staff wrote in `request.ticket`. `jev_answered` shows how "
            "the ticket was routed, which may rely on things learned earlier: do not count "
            "anything it says or implies."
        ),
        criteria=NoulCriteria(
            true=(
                "Yes: in `request.ticket` the customer or our support staff state something "
                "about the customer that stays true after this ticket, such as the product "
                "edition or operating system they run, their plan or contract, or a standing "
                "preference."
            ),
            false=(
                "No: `request.ticket` only contains things specific to this one ticket or "
                "moment, such as a one-off request, today's schedule, or the device they "
                "happen to be typing on; or the lasting fact appears only in `jev_answered`."
            ),
        ),
    ),
}
```

The "fact only in `jev_answered` does not count" rule is deliberate: without it, the cookbook says, a generic answer such as "General product questions" can outweigh a real fact in the request, and an answer that relied on an earlier memory can be saved again as if it were new.

## Combining logic in code

```python
import json
import os
from pathlib import Path

from cooksafe import JsonCache
from typesafe_sdk import Choice, Noul, NoulCriteria, SystemOneResponse, TypeSafeClient

TYPESAFE_MODEL = "jev-1.13.0"
REMEMBER_AT = 0.5  # save when P(lasting customer fact) reaches this starting threshold
MEMORY_KEY = "earlier_tickets_from_this_customer"  # where saved tickets go in the state

client = TypeSafeClient(
    api_key=os.environ.get("TYPESAFE_API_KEY", "cache-only"),  # keyless kernels replay the cache
    base_url=os.environ.get("TYPESAFE_ENDPOINT"),
    timeout=120.0,
)
json_cache = JsonCache(Path("json_cache.json"))

# ROUTE and GATE as above.


@json_cache
def _system_one(model: str, state: str, questions: str) -> dict:
    response = client.system_one(
        state=json.loads(state), questions=json.loads(questions), model=model
    )
    return response.model_dump(mode="json")


def ask(state: dict, questions: dict) -> SystemOneResponse:
    """client.system_one, with the model pinned and the call cached."""
    as_json = {qid: q.model_dump(mode="json", exclude_none=True) for qid, q in questions.items()}
    raw = _system_one(TYPESAFE_MODEL, json.dumps(state), json.dumps(as_json))
    return SystemOneResponse.model_validate(raw)


def answer_text(question, answer):
    """The readable part of an answer: the chosen option's description, or the number."""
    if answer.type == "choice":
        return question.criteria[answer.choice]
    return answer.noul if answer.type == "noul" else answer.score


def as_memory(state: dict, questions: dict, response: SystemOneResponse) -> dict:
    return {
        "request": state,
        "jev_answered": {
            q.instructions: answer_text(q, response.answers[qid])
            for qid, q in questions.items()
        },
    }


def remember_probability(memory: dict) -> float:
    """P(the request states an important fact about the customer), with the answer as context."""
    return ask(memory, GATE).answers["remember"].noul


MEMORIES: dict[str, list[dict]] = {}  # customer ID -> that customer's saved memories


def customer_id(state: dict) -> str:
    return state["customer"]["id"]


def ask_with_memory(state: dict, questions: dict) -> SystemOneResponse:
    """ask(), plus memory: same arguments, same return value."""
    # 1. Answer the call with this customer's existing memories.
    saved = MEMORIES.get(customer_id(state), [])
    response = ask({**state, MEMORY_KEY: saved} if saved else state, questions)

    # 2. Ask Jev whether the original request states an important fact worth keeping.
    memory = as_memory(state, questions, response)
    if remember_probability(memory) >= REMEMBER_AT:
        MEMORIES.setdefault(customer_id(state), []).append(memory)
    return response


# Usage: same call shape as ask().
# team = ask_with_memory(ticket_state, ROUTE).answers["team"]
# print(team.choice, team.probabilities[team.choice])
```

`ask_with_memory()` takes the same arguments and returns the same response as `ask()`, so it swaps in without changing the routing code; `ROUTE` never mentions memories, because Jev reads the whole `state`.

## Results / what the cookbook reports

Earlier tickets through the wrapper (gate probability, saved or not):

| ticket | customer | routed to | P(remember) | saved |
|---|---|---|---|---|
| T-101 Windows Server 2022, own data centre | C-1042 | `selfhosted_windows` | 0.91 | yes |
| T-102 cloud edition, self-serve Team plan | C-2210 | `general` | 0.91 | yes |
| T-103 typing on a phone at the airport | C-1042 | `billing` | 0.07 | no |
| T-104 team out Friday for a public holiday | C-2210 | `general` | 0.10 | no |
| T-105 Enterprise contract signed in March | C-1042 | `enterprise_desk` | 0.92 | yes |

T-102 was saved although it was routed to `general`: the gate counts the customer's words, not the answer.

Later messages, plain `ask()` vs `ask_with_memory()`:

| customer | message | expected | plain Jev | with memory |
|---|---|---|---|---|
| C-1042 | Slow dashboards | `selfhosted_windows` | ✗ `cloud_ops` 0.80 | ✓ `selfhosted_windows` 0.94 |
| C-1042 | Upgrade failing | `selfhosted_windows` | ✗ `none_suitable` 0.60 | ✓ `selfhosted_windows` 1.00 |
| C-1042 | More seats | `enterprise_desk` | ✓ `enterprise_desk` 0.62 | ✓ `enterprise_desk` 0.99 |
| C-2210 | Slow dashboards | `cloud_ops` | ✓ `cloud_ops` 0.75 | ✓ `cloud_ops` 0.91 |
| C-2210 | Upgrade failing | `cloud_ops` | ✗ `none_suitable` 0.63 | ✓ `cloud_ops` 0.86 |
| C-2210 | More seats | `billing` | ✗ `enterprise_desk` 0.52 | ✓ `billing` 0.98 |

"Expected routes: 2/6 without memory, 6/6 with memory." The same message goes to different teams for different customers. Adding the Enterprise memory lowered C-1042's slow-dashboards answer from 0.99 to 0.94, which the cookbook gives as one reason to choose which memories to add. The gate saved none of the repeated or later messages.

## Adapting it to a new domain

Per the cookbook's Next steps:

- Edit `GATE` to describe what is worth remembering for your task, and test `REMEMBER_AT` on examples you have labelled.
- Add a relevance question to pick a smaller set of memories once the list grows.
- Ask whether a new memory replaces an older one, and remove the old entry in code.
- Replace `MEMORIES` with a database keyed by customer ID; the dict disappears when the process exits.
- Map each team to a handler in code ([[patterns/intent-routing]], [[patterns/confidence-routing]]); add your reply to the ticket thread as a support message so the gate can remember what you did.

Choosing the threshold (appendix): label request-and-answer pairs from your traffic as worth remembering or not, find the probability that best separates the two groups, and check it on pairs you did not use to pick it. Raise it if a misleading memory costs more than a missing one; lower it if the reverse ([[concepts/confidence]]).

## Gotchas

- **Two sequential requests per call.** The gate reads the answer, so it runs after the routing request. If your gate ignores the answer, it can instead be a second question in the routing request ([[patterns/fan-out]]).
- **Memory only grows.** Every saved memory goes into every later call for that customer: no size limit, relevance check or expiry here. Long lists eventually press on the 32k limit for state plus the longest question ([[reference/models-and-pricing]]) and add input tokens to every call (inferred).
- **A saved answer can reinforce a mistake.** The gate ignores facts that appear only in Jev's answer, but once a ticket is saved, its answer is saved with it, mistaken inference and all, and later calls read it (appendix).
- **The state needs `customer.id`.** Plain Jev accepts a string as the state; this wrapper does not.
- **0.5 is a starting point**, not a tuned threshold; the published numbers are one synthetic example of two customers and six later messages.
- **Install line.** `pip install ipython 'cooksafe>=0.2.0,<0.3.0'`; set `TYPESAFE_API_KEY`. Every call is cached in [`json_cache.json`](https://github.com/typesafe-ai/typesafe-public-examples/blob/main/cookbooks/basic_memory/json_cache.json) in `typesafe-ai/typesafe-public-examples`; download it to replay the published numbers, or leave it out to run live. The cookbook also builds a playground share link with `make_playground_link` for C-1042's first slow-dashboards call.

## Related

- [[concepts/state]] — Jev sees only what you send; memory is code that adds to `state`
- [[concepts/noul]] — the gate is one probability
- [[cookbooks/classification-using-confidence]] — the routing Choice with confidence
- [[patterns/intent-routing]] — acting on the route
- [[ideas/patterns-agents]] — community memory and context patterns (P07 and others)
- [[ideas/builder-lessons]] — builders' results on putting context in state
- [[cookbooks/overview]] — the full cookbook catalog

## Sources

- raw/docs/cookbooks__basic_memory.md (https://docs.typesafe.ai/cookbooks/basic_memory.md), first captured 2026-10-11
