# Threadless AI: all docs > Hosted memory for AI agents. Before every model call your agent hands over what is new in the conversation and gets back the request to submit to OpenAI (Chat Completions or Responses); after the reply it reports it. Old episodes are forgotten by time, their facts are kept, and recall brings them back when the conversation turns to them. --- # Set up Threadless AI > Exact steps for an agent adding Threadless AI to a codebase that calls OpenAI, from install to one verified call. **What this is, in five lines** 1. Threadless AI is hosted memory for an agent that calls OpenAI (Chat Completions or Responses). 2. Before every model call, `decorate` takes what is new in the conversation and returns the request body to submit. 3. After the reply, `report` hands over the response; it returns at once and never throws. 4. Old history leaves the model's view by time and use, its facts are kept, and recall brings them back when relevant. 5. The OpenAI call stays in this codebase, with its own key; it never passes through Threadless AI. Follow the steps in order. Each code block can be copied as is. Replace only what is marked `your…`. ## Step 1: find the model call Find where this codebase calls `chat.completions.create` or `responses.create`, and the loop around it that handles tool calls. You will wrap that call. Note which API it uses; the steps below show both. ## Step 2: install the SDK TypeScript or JavaScript: ```bash npm install @threadlessai/sdk ``` Python: ```bash pip install threadlessai ``` ## Step 3: get a project key Either: - ask the user to create a key in the console at https://threadlessai.com/app and give it to you; or - if the admin MCP server is connected (https://mcp.threadlessai.com/admin), create a project and a key with it. Use a test key (`tl_test_…`) while developing. Put it in the environment, next to the OpenAI key, never in source: ```bash THREADLESS_API_KEY=tl_test_your-key ``` ## Step 4: create a memory per conversation One memory per conversation. Create it when the conversation is created, and store its id with the conversation (a database column, or wherever the conversation id lives). ```ts import { Threadless } from '@threadlessai/sdk'; export const memory = new Threadless(); // reads THREADLESS_API_KEY const { id: memoryId } = await memory.memories.create({ metadata: { conversation: yourConversationId } }); ``` ```python from threadlessai import Threadless memory = Threadless() # reads THREADLESS_API_KEY memory_id = memory.memories.create(metadata={"conversation": your_conversation_id})["id"] ``` If the user can edit a past message or branch the conversation, the edited or branched conversation gets a new memory. ## Step 5: wrap the model call Replace the call so that every request goes through `decorate`, and every reply through `report`. Pass only what is new since the last call: the user's message, or the model's reply plus the tool results. Keep the system message in the first call; it is stored and leads every request. Chat Completions, TypeScript: ```ts const request = await memory.chat.decorate(memoryId, newMessages, { model: yourModel, tools }); const reply = await openai.chat.completions.create( request as unknown as OpenAI.ChatCompletionCreateParamsNonStreaming, ); memory.chat.report(memoryId, reply); ``` Responses, TypeScript: ```ts const request = await memory.responses.decorate(memoryId, newItems, { model: yourModel, tools }); const reply = await openai.responses.create(request as OpenAI.Responses.ResponseCreateParamsNonStreaming); memory.responses.report(memoryId, reply); ``` Chat Completions, Python: ```python request = memory.chat.decorate(memory_id, new_messages, {"model": your_model, "tools": tools}) reply = openai.chat.completions.create(**request) memory.chat.report(memory_id, reply) ``` Responses, Python: ```python request = memory.responses.decorate(memory_id, new_items, {"model": your_model, "tools": tools}) reply = openai.responses.create(**request) memory.responses.report(memory_id, reply) ``` Do not keep sending the full history yourself. `decorate` returns it. ## Step 6: register the note tool Add the note tool to the tools list of every request: ```ts const tools = [...yourTools, memory.chat.noteTool]; // Responses: memory.responses.noteTool ``` ```python tools = [*your_tools, memory.chat.note_tool] # Responses: memory.responses.note_tool ``` ## Step 7: answer note calls with NOTE_RESULT In the tool-call handler, answer every call to the note tool with `NOTE_RESULT` (the string `noted`). Do not run anything for it. Then continue the loop as after any tool call. ```ts import { isNoteCall, NOTE_RESULT } from '@threadlessai/sdk'; // Chat Completions const output = isNoteCall(call.function.name) ? NOTE_RESULT : await yourRunTool(call); newMessages.push({ role: 'tool', tool_call_id: call.id, content: output }); // Responses const output = isNoteCall(call.name) ? NOTE_RESULT : await yourRunTool(call); newItems.push({ type: 'function_call_output', call_id: call.call_id, output }); ``` ```python from threadlessai import NOTE_RESULT, is_note_call # Chat Completions output = NOTE_RESULT if is_note_call(call.function.name) else your_run_tool(call) new_messages.append({"role": "tool", "tool_call_id": call.id, "content": output}) # Responses output = NOTE_RESULT if is_note_call(call.name) else your_run_tool(call) new_items.append({"type": "function_call_output", "call_id": call.call_id, "output": output}) ``` A complete loop for each API and language is in the [quickstart](https://threadlessai.com/docs/quickstart.md). ## Step 8: verify with one call Run this with the key from step 3. It creates a memory and decorates one turn. ```bash MEMORY_ID=$(curl -s https://api.threadlessai.com/v1/memories -X POST \ -H "Authorization: Bearer $THREADLESS_API_KEY" -H 'content-type: application/json' -d '{}' \ | sed -E 's/.*"id":"([^"]+)".*/\1/') curl -s https://api.threadlessai.com/v1/memories/$MEMORY_ID/decorate \ -H "Authorization: Bearer $THREADLESS_API_KEY" -H 'content-type: application/json' \ -d '{"format":"chat.completions","messages":[{"role":"system","content":"You are terse."},{"role":"user","content":"Hello"}],"request":{"model":"gpt-5"}}' ``` Success looks like this: `memory.turn` is `1`, and `request` is your request echoed back, with the history you sent. Numbers marked `…` vary. ```json { "request": { "model": "gpt-5", "messages": [ { "role": "system", "content": "You are terse." }, { "role": "user", "content": "Hello" } ] }, "memory": { "id": "mem_…", "revision": …, "turn": 1, "notes_requested": false, "recalled": 0, "facts_in_view": 0, "tokens": … } } ``` If it fails, the response says why: - `401 unauthorized`: the key is missing, revoked, or an admin key (`tla_…`) rather than a project key. - `404 not_found`: the memory id is not in this key's project. - `400 invalid_request`: the body does not match; the `message` names the field. Then run the codebase's own agent once and check that it still answers. If it does, setup is done. ## Reference - Docs index for agents: https://threadlessai.com/llms.txt - All docs in one file: https://threadlessai.com/llms-full.txt - OpenAPI spec: https://threadlessai.com/openapi/runtime.yaml --- # Overview > What Threadless AI does, the three calls it takes, and where to start. Threadless AI is hosted memory for AI agents. Before every model call, your agent hands over what is new in the conversation and gets back the request ready to submit to OpenAI. After the reply, it reports the reply. That is the whole integration. Between those calls we keep the conversation's history in proportion. Old episodes leave the model's view when they matter least, their facts stay behind, and recall brings them back when the conversation turns to them. The model you call, the key you call it with and your agent loop stay yours. ## The three calls 1. **decorate**, before every model call: send what is new since the last call, get back the full request body. 2. **Submit the request** to OpenAI with your own key, exactly as you do now. 3. **report**, after the reply: hand over the response. It returns at once and the work runs in the background. Add the **note tool** to your tools once. When episodes are leaving the model's view, the request asks the model to write their facts with it; your handler answers `noted` and nothing else. ## Where to start - [Quickstart](https://threadlessai.com/docs/quickstart): a working loop in TypeScript or Python, for Chat Completions or Responses. - [The three calls](https://threadlessai.com/docs/calls): decorate, report and the note tool in detail. - [Memory ids](https://threadlessai.com/docs/memory-ids): one memory per conversation. - [Concepts](https://threadlessai.com/docs/concepts): episodes, traces, activation, fading, sleep and recall. - [API reference](https://threadlessai.com/docs/api-reference): the HTTP API, if you are not using an SDK. If you are an agent setting this up, read [/setup.md](https://threadlessai.com/setup.md) and follow it step by step. Every docs page is also served as markdown at its path plus `.md`, and [/llms.txt](https://threadlessai.com/llms.txt) indexes them. ## SDKs | Language | Package | Install | |---|---|---| | TypeScript | `@threadlessai/sdk` | `npm install @threadlessai/sdk` | | Python | `threadlessai` | `pip install threadlessai` | Both are thin clients over the [runtime API](https://threadlessai.com/docs/api-reference) at `https://api.threadlessai.com/v1`. Each surface (`chat` for Chat Completions, `responses` for Responses) has `decorate`, `report` and the note tool. --- # Quickstart > Install the SDK, create a memory, and run an agent loop through decorate and report, in TypeScript or Python, for Chat Completions or Responses. ## 1. Install and set your key Get a project key from the [console](https://threadlessai.com/app). Live keys start `tl_live_`, test keys `tl_test_`. ```bash npm install @threadlessai/sdk # TypeScript pip install threadlessai # Python export THREADLESS_API_KEY=tl_test_... ``` Your OpenAI key stays in your process. Requests go from your code straight to OpenAI; they never pass through us. ## 2. Create a memory per conversation A memory holds one conversation. Create it when the conversation starts and keep its id next to your conversation id. ```ts import { Threadless } from '@threadlessai/sdk'; const memory = new Threadless(); // reads THREADLESS_API_KEY const { id: memoryId } = await memory.memories.create({ metadata: { conversation: 'c_123' } }); ``` ```python from threadlessai import Threadless memory = Threadless() # reads THREADLESS_API_KEY memory_id = memory.memories.create(metadata={"conversation": "c_123"})["id"] ``` ## 3. Run the loop: Chat Completions Each call sends only what is new since the last one: the user's message, or the model's reply plus your tool results. Send your system message on the first call and whenever it changes; it is kept and always leads the request. ### TypeScript ```ts import OpenAI from 'openai'; import { isNoteCall, NOTE_RESULT, Threadless } from '@threadlessai/sdk'; const openai = new OpenAI(); // reads OPENAI_API_KEY const memory = new Threadless(); const tools = [memory.chat.noteTool /* , ...your own tools */]; export async function send(memoryId: string, text: string) { let newMessages: Array> = [{ role: 'user', content: text }]; for (;;) { const request = await memory.chat.decorate(memoryId, newMessages, { model: 'gpt-5', tools }); const reply = await openai.chat.completions.create( request as unknown as OpenAI.ChatCompletionCreateParamsNonStreaming, ); memory.chat.report(memoryId, reply); const message = reply.choices[0].message; if (!message.tool_calls?.length) return message.content; newMessages = [{ ...message }]; for (const call of message.tool_calls) { if (call.type !== 'function') continue; const output = isNoteCall(call.function.name) ? NOTE_RESULT : await runTool(call); newMessages.push({ role: 'tool', tool_call_id: call.id, content: output }); } } } ``` ### Python ```python from openai import OpenAI from threadlessai import NOTE_RESULT, Threadless, is_note_call openai = OpenAI() # reads OPENAI_API_KEY memory = Threadless() tools = [memory.chat.note_tool] # plus your own tools def send(memory_id: str, text: str): new_messages = [{"role": "user", "content": text}] while True: request = memory.chat.decorate(memory_id, new_messages, {"model": "gpt-5", "tools": tools}) reply = openai.chat.completions.create(**request) memory.chat.report(memory_id, reply) message = reply.choices[0].message if not message.tool_calls: return message.content new_messages = [message.model_dump(exclude_none=True)] for call in message.tool_calls: output = NOTE_RESULT if is_note_call(call.function.name) else run_tool(call) new_messages.append({"role": "tool", "tool_call_id": call.id, "content": output}) ``` ## 4. Or run the loop: Responses The same loop with `responses`: items instead of messages, `function_call_output` for tool results. ### TypeScript ```ts import OpenAI from 'openai'; import { isNoteCall, NOTE_RESULT, Threadless } from '@threadlessai/sdk'; const openai = new OpenAI(); const memory = new Threadless(); const tools = [memory.responses.noteTool /* , ...your own tools */]; export async function send(memoryId: string, text: string) { let newItems: Array> = [{ role: 'user', content: text }]; for (;;) { const request = await memory.responses.decorate(memoryId, newItems, { model: 'gpt-5', tools }); const reply = await openai.responses.create( request as OpenAI.Responses.ResponseCreateParamsNonStreaming, ); memory.responses.report(memoryId, reply); const calls = reply.output.filter((item) => item.type === 'function_call'); if (calls.length === 0) return reply.output_text; newItems = reply.output.map((item) => ({ ...item })); for (const call of calls) { const output = isNoteCall(call.name) ? NOTE_RESULT : await runTool(call); newItems.push({ type: 'function_call_output', call_id: call.call_id, output }); } } } ``` ### Python ```python from openai import OpenAI from threadlessai import NOTE_RESULT, Threadless, is_note_call openai = OpenAI() memory = Threadless() tools = [memory.responses.note_tool] # plus your own tools def send(memory_id: str, text: str): new_items = [{"role": "user", "content": text}] while True: request = memory.responses.decorate(memory_id, new_items, {"model": "gpt-5", "tools": tools}) reply = openai.responses.create(**request) memory.responses.report(memory_id, reply) calls = [item for item in reply.output if item.type == "function_call"] if not calls: return reply.output_text new_items = [item.model_dump(exclude_none=True) for item in reply.output] for call in calls: output = NOTE_RESULT if is_note_call(call.name) else run_tool(call) new_items.append({"type": "function_call_output", "call_id": call.call_id, "output": output}) ``` ## 5. Check it worked The SDK's `decorate` returns the request body only. The HTTP API returns a `memory` object beside it ([DecorateResult](https://threadlessai.com/docs/api-reference#decorateresult)), so one call with `curl` shows the memory working: ```bash MEMORY_ID=$(curl -s https://api.threadlessai.com/v1/memories -X POST \ -H "Authorization: Bearer $THREADLESS_API_KEY" -H 'content-type: application/json' -d '{}' \ | sed -E 's/.*"id":"([^"]+)".*/\1/') curl -s https://api.threadlessai.com/v1/memories/$MEMORY_ID/decorate \ -H "Authorization: Bearer $THREADLESS_API_KEY" -H 'content-type: application/json' \ -d '{"format":"chat.completions","messages":[{"role":"system","content":"You are terse."},{"role":"user","content":"Hello"}],"request":{"model":"gpt-5"}}' ``` On the first call of a new memory, `memory.turn` is `1` and the request's history is what you sent. As the conversation grows, `facts_in_view` and `recalled` rise, and `notes_requested` is true on calls that ask the model for notes. `memory.memories.get(memoryId)` shows a memory's size and activity: turns, messages, episodes by state, facts and stored bytes. ## Next - [The three calls](https://threadlessai.com/docs/calls) explains what to send and what comes back. - [Failure behaviour](https://threadlessai.com/docs/failure-behaviour) explains what happens when we cannot be reached: your model call still goes ahead. --- # The three calls > decorate before every model call, report after it, and the note tool the model writes facts with, answered with NOTE_RESULT. ## decorate Call it before every model call, including every call inside a tool loop. ```ts const request = await memory.chat.decorate(memoryId, newMessages, requestFields); ``` ```python request = memory.chat.decorate(memory_id, new_messages, request_fields) ``` **`newMessages`** is everything new since the last call, in the API's own shape: - the first call of a conversation: your system or developer message and the user's message; - a new user turn: the model's last reply and the user's message; - inside a tool loop: the model's reply and your tool results. Replies you already reported are recognised and not stored twice, so sending the reply again is safe and expected. System and developer messages are the instructions: the latest set you send replaces the stored one and always leads the request. Send them once and again only when they change. **`requestFields`** is the rest of the request body as you would send it to OpenAI, without the history: `model`, `tools`, `tool_choice`, `temperature`, `instructions` and the rest. They pass through unchanged. `previous_response_id` and `store` are dropped, because we hold the history. **It returns** the full request body, ready to submit with your own key. Its history has: 1. your instructions; 2. the facts block: facts remembered from history that has left the view, most active first; 3. the history still in view, with each turn's recall block before its user message; 4. the note ask, last, on calls where episodes are leaving. The HTTP API also returns a `memory` object: the current `turn`, whether notes were requested, how many facts were recalled and are in view, and our estimate of the request size. The `Server-Timing` header carries each step's duration. ## report Call it after every model reply, with the response exactly as OpenAI returned it, usage included. ```ts memory.chat.report(memoryId, reply); ``` ```python memory.chat.report(memory_id, reply) ``` It returns at once and never throws; failures go to the `onError` callback (`on_error` in Python). The reply is stored, notes in it are taken, and the work that can wait runs in the background: scoring facts, indexing what left the view, preparing recall for the next message. The usage figures in the reply let us measure our size estimate against the provider's count, which keeps forgetting on target for your model. If a report is still in flight when the next `decorate` on the same memory starts, the SDK waits briefly for it. ## The note tool Add it to your tools once: ```ts const tools = [memory.chat.noteTool, ...yourTools]; // or memory.responses.noteTool ``` ```python tools = [memory.chat.note_tool, *your_tools] # or memory.responses.note_tool ``` When part of the history is leaving the model's view, the request ends with the note ask: an instruction to begin or end the response with one call to the note tool holding that history's facts. The model writes each fact as a knowledge-graph triple: a sentence that reads on its own, with its subject and object under their canonical names, and marks milestones: what was agreed, decided, done, blocked or next. **Nothing runs.** The call itself is the note: its arguments reach us in the reply you report. Your handler answers every note call with `noted` as the tool result and does nothing else. ```ts import { isNoteCall, NOTE_RESULT } from '@threadlessai/sdk'; const output = isNoteCall(call.function.name) ? NOTE_RESULT : await runTool(call); ``` ```python from threadlessai import NOTE_RESULT, is_note_call output = NOTE_RESULT if is_note_call(call.function.name) else run_tool(call) ``` Answer it like any tool call: Chat Completions needs a `tool` message for every tool call before the next request, and Responses a `function_call_output`. Then call `decorate` again with the reply and the results, as you would after any tool call. The tool is named `memory_note`. Its definition for Chat Completions: ```json { "type": "function", "function": { "name": "memory_note", "description": "Writes facts into this conversation's long-term memory. Nothing runs: the call itself is the note. When the system asks for facts from history that is leaving live context, answer with one call holding every fact: one entry per fact, a knowledge-graph triple readable on its own, identifiers and the source's own terms verbatim. Once those turns leave, these facts are what stays in view of them. When they hold nothing needed later, one entry with an empty note.", "parameters": { "type": "object", "properties": { "notes": { "type": "array", "description": "Every fact being written, one entry each.", "items": { "type": "object", "properties": { "note": { "type": "string", "description": "One fact, as a sentence readable on its own. Empty when the history leaving holds nothing needed later." }, "subject": { "type": "string", "description": "The triple's subject entity, resolved to its canonical name (entity resolution): the name earlier facts already use, else the most specific name in the conversation." }, "object": { "type": "string", "description": "The triple's object entity, resolved to its canonical name (entity resolution). The fact becomes the edge between subject and object in a knowledge graph." }, "milestone": { "type": "boolean", "description": "True when the fact says where the work stands (a milestone reached, a point of alignment, what is next or blocked)." } }, "required": [ "note", "subject", "object", "milestone" ], "additionalProperties": false } } }, "required": [ "notes" ], "additionalProperties": false }, "strict": true } } ``` For Responses, the same fields sit at the top level of the tool: ```json { "type": "function", "name": "memory_note", "description": "Writes facts into this conversation's long-term memory. Nothing runs: the call itself is the note. When the system asks for facts from history that is leaving live context, answer with one call holding every fact: one entry per fact, a knowledge-graph triple readable on its own, identifiers and the source's own terms verbatim. Once those turns leave, these facts are what stays in view of them. When they hold nothing needed later, one entry with an empty note.", "parameters": { "type": "object", "properties": { "notes": { "type": "array", "description": "Every fact being written, one entry each.", "items": { "type": "object", "properties": { "note": { "type": "string", "description": "One fact, as a sentence readable on its own. Empty when the history leaving holds nothing needed later." }, "subject": { "type": "string", "description": "The triple's subject entity, resolved to its canonical name (entity resolution): the name earlier facts already use, else the most specific name in the conversation." }, "object": { "type": "string", "description": "The triple's object entity, resolved to its canonical name (entity resolution). The fact becomes the edge between subject and object in a knowledge graph." }, "milestone": { "type": "boolean", "description": "True when the fact says where the work stands (a milestone reached, a point of alignment, what is next or blocked)." } }, "required": [ "note", "subject", "object", "milestone" ], "additionalProperties": false } } }, "required": [ "notes" ], "additionalProperties": false }, "strict": true } ``` ### Without the note tool A call that asks for facts carries the note tool whether or not your request had it, so the model can always answer. Handle `memory_note` calls in your tool loop and answer them with `noted` (the SDKs' `isNoteCall` and `NOTE_RESULT`). If a run ends without the note it was asked for, that history stays leaving and the next call asks again. Only the model writes facts: it was there, so it writes the best ones. --- # Memory ids > One memory per conversation. A memory only grows, so a branch or an edit is a new memory. A memory is one conversation. Its id is the only scope there is: every read and write names it, a request without one is refused, and recall searches within that one memory, never across your other memories. ## The shape `POST /memories` returns the id. It is `mem_` followed by 24 letters and digits, for example `mem_8fK2qLmN4pR7sT1vW3xY5zA9`. Store it next to your own conversation id; you can also put your id in the memory's `metadata` (up to 16 string pairs). ```ts const { id } = await memory.memories.create({ metadata: { conversation: 'c_123', user: 'u_9' } }); ``` A memory belongs to the project of the key that created it. A key can only reach its own project's memories; any other id answers `404 not_found`. ## One per conversation Create a memory when a conversation starts and use it for every call in that conversation, tool loops included. Two conversations never share a memory: facts from one would be recalled into the other. A conversation can run for months. There is no length at which you need a new memory; that is what forgetting is for. ## A memory only grows Messages are appended, never rewritten. That has two consequences: - **Editing a past message** (the user edits turn 12 and regenerates): start a new memory. Send it the conversation as it now stands, up to the edit, in its first `decorate`. - **Branching** (two continuations from one point): each branch is its own memory, seeded the same way. Only what is new since the last call belongs in `newMessages`. The one exception is the model's last reply, which is recognised and not stored twice; any other message you send again is stored again, as new. ## Deleting and exporting - `memory.memories.delete(id)` removes everything the memory holds and checks it is gone before it returns. It cannot be undone. - `memory.memories.export(id)` returns every message, episode and fact as JSON. See [Security](https://threadlessai.com/security) for what is stored and how deletion is verified. --- # Models and formats > Chat Completions and Responses, which models work, where our messages go in the request, and what the size ceiling assumes. ## Two formats Every call names the OpenAI API it is for. The SDKs do this for you: `memory.chat` is Chat Completions and `memory.responses` is Responses. | Format | SDK surface | History field | Tool results | |---|---|---|---| | `chat.completions` | `memory.chat` | `messages` | `tool` messages | | `responses` | `memory.responses` | `input` items | `function_call_output` items | A memory keeps each message in the format it arrived in, and messages come back exactly as stored when the request is in the same format. A memory can switch format mid-conversation: messages from the other format are converted to their text, tool calls and tool outputs, and anything else (images, reasoning items) is left out of the converted copy. Messages and items are stored as you send them: content parts, images, tool calls, tool outputs and reasoning items included. Whatever you put in `requestFields` (`model`, `tools`, `tool_choice`, `temperature`, `reasoning`, `instructions` and the rest) passes through unchanged, except `previous_response_id` and `store`, which are dropped because we hold the history. ## What we add to the request Our own text reaches the model as instruction messages, in the role your instructions use: - Chat Completions: `system` messages, or `developer` messages when your instructions are `developer` messages. - Responses: `developer` message items. There are three kinds: - **The facts block**, right after your instructions: facts remembered from history that has left the view. - **The recall block**, just before the user message it was recalled for, between `[recalled_memories]` and `[/recalled_memories]`: possible memories from earlier in the conversation, dated, to be used only when relevant. - **The note ask**, last, only on calls where history is leaving the view. None of them names a tool. The model knows the note tool by its description. See [How it works](https://threadlessai.com/how-it-works#what-the-model-sees) for an example of each. ## Models Any model that the API you use accepts works. The request you get back is the request you would have sent, with the history managed; we don't call the model. The note ask relies on the model calling a tool when asked, so use a model that supports function calling. The note is how facts get written, and history leaves the view only once its facts are written. ## The size ceiling The ceiling the history is kept under is ours and constant: we assume a 200k-token context window and keep room for the reply. A model with a smaller window is not refused; whether to use one is your call. Sizes are measured with our own estimate and corrected by the token count your provider reported on the previous call, which is why `report` should carry the response's `usage`. ## Streaming `report` takes the complete response. If you stream, report the final response once the stream ends: for Responses, the `response` of the `response.completed` event; for Chat Completions, a completion object assembled from the stream, with `usage` when you request it (`stream_options: { include_usage: true }`). --- # Failure behaviour > When we cannot be reached, the SDK builds the request from a local tail of recent turns and your model call goes ahead. Memory never fails the model call. Memory must never be the reason your agent stops answering. The SDKs are built around that. ## decorate falls back to the local tail The SDK keeps a **local tail** for each memory, in your process: your instructions and the newest turns of the last request, up to a size you set. When `decorate` cannot get an answer from us, it returns a request built from that tail plus the new messages, and your model call goes ahead. It falls back on: - a network error or a timeout (15 seconds by default, `timeoutMs` / `timeout`); - a `5xx` response; - `429` (rate limited, or the plan's quota used up) and `408`. Every fallback is reported to your `onError` callback (`on_error` in Python) with the operation and memory id, so you can log it. It does **not** fall back on other `4xx` responses. A malformed request, a revoked key or an unknown memory id is a bug for you to see, so `decorate` throws a `ThreadlessError` with the status, a `code`, a `message` and a `hint` saying what to do about it. ```ts const memory = new Threadless({ timeoutMs: 15_000, tailTokens: 60_000, onError: (error, { operation, memoryId }) => log.warn({ error, operation, memoryId }), }); ``` ```python memory = Threadless( timeout=15.0, tail_tokens=60_000, on_error=lambda error, context: log.warning("threadless %s %s", context, error), ) ``` ## What the tail holds - The latest instructions you sent. - The newest history that fits in `tailTokens` (60,000 estimated tokens by default), cut at a user message so a tool call is never separated from its output. - If not even one turn fits, the newest user message onward. The tail is refreshed from every successful `decorate`, so it mirrors what we last sent you, facts block and recall included. It lives in memory only: a process that restarts has no tail, and the first fallback after a restart carries just the new messages and instructions. The SDK keeps tails for the 200 most recently used memories. While the fallback is in use, nothing is forgotten, no facts are added and nothing is recalled; the history we hold resumes from the next successful call. Messages sent during an outage are not stored by us. ## report never throws `report` runs in the background. If it fails, `onError` is told and the call is not retried; your agent is never interrupted. If a report is still in flight when the next `decorate` on the same memory starts, the SDK waits up to two seconds for it, so the next request sees the reply. ## Errors from the HTTP API Every error has one shape: ```json { "error": { "code": "not_found", "message": "…", "hint": "Use an id that POST /v1/memories returned." } } ``` | Code | Status | Meaning | |---|---|---| | `invalid_request` | 400 | The body does not match the schema. | | `unauthorized` | 401 | The key is missing, revoked or not a project key. | | `not_found` | 404 | No memory with this id in this key's project. | | `rate_limited` | 429 | Too many requests for this key. | | `quota_exceeded` | 429 | The plan's allowance is used up. | | `internal` | 500 | Something went wrong on our side. | --- # Limits > Request size, request rate, metadata, memory ids, the size ceiling and the free plan's allowances. | Limit | Value | When it is hit | |---|---|---| | Request body | 16 MB | `400 invalid_request` | | Request rate | 50 requests a second per key, bursts up to 200 | `429 rate_limited`; retry after a second | | Memory metadata | 16 pairs; keys up to 64 characters, values up to 512 | `400 invalid_request` | | Memory id | `mem_` and 24 letters and digits | `400 invalid_request` | | Context window assumed | 200k tokens | Nothing is refused; see below | | Free plan | A monthly allowance of user turns and a cap on memories | `429 quota_exceeded` | The request rate protects the service; it is not a plan limit. `decorate` treats a `429` like an outage and [falls back to the local tail](https://threadlessai.com/docs/failure-behaviour), so your model call still goes ahead. ## The size ceiling We keep the history under a ceiling that assumes a 200k-token context window, with room left for the reply. It is the same for every customer and every model, and it does not change with your plan. A model with a smaller window is not refused. The part of a request that forgetting cannot shrink (your instructions, your tools and the facts block) is measured on every call and subtracted first. The current user turn is never forgotten. If your instructions and tools alone fill most of a 200k window, forgetting has little room to work with. ## Turns A turn is a `decorate` call that carries a new user message. Calls inside a tool loop, which carry only the model's reply and tool results, are not turns. Turns are what the [user turns meter](https://threadlessai.com/pricing) counts and what the free plan's monthly allowance counts. ## The free plan The free plan's allowances are being finalised with pricing. When one is used up, `decorate` and `POST /memories` answer `429 quota_exceeded` with a message naming the allowance; `decorate` falls back to the local tail meanwhile. --- # Concepts > Episodes, encoding, traces, activation, fading, sleep, significance and recall, named for the human memory they mimic. A memory is modelled on how people remember: experiences in episodes, what was learned kept as facts, availability that fades with real time, and cues that bring old things back. This page names each piece. [How it works](https://threadlessai.com/how-it-works) follows a request through them. ## Episodes An **episode** is a stretch of the conversation about one concept, made of whole messages. A user message is never split, and a tool call is never separated from its output. Where one concept ends and the next begins is a judgment made for every message; sizes only decide how many cuts there are. One request that touches three topics becomes three episodes. An episode moves through four states: | State | Its messages | Its facts | |---|---|---| | live | in the model's view | not written yet | | leaving | in view; the model is asked for their facts | being written | | sealed | out of view | in the facts block | | forgotten | out of view | out of the facts block; kept for recall | Nothing is deleted when an episode is forgotten. Its messages, its facts and its passages stay in the memory and can be recalled. ## Facts A **fact** is one sentence that reads on its own, with its subject and object: a knowledge-graph triple. Facts are written by the model, with the note tool, as episodes leave. Each memory has its own knowledge graph, where the same entity under different names becomes one, a restated fact merges with the one already held, and a newer fact retires one it contradicts. A **milestone** is a fact that says where the work stands: agreed, decided, done, blocked, next. ## Encoding When an episode forms it is **encoded**: it gets a first trace at full strength, stamped with the time it happened. Whatever just happened is the most available thing in the conversation, before anything else can compete with it. ## Traces A **trace** records one time a fact mattered to an episode. Every new fact is judged against every episode still remembered ("is this fact about that episode?"), and each answer is stored with its time. Only a yes counts. Traces are never changed after they are written, so the record of what mattered when is permanent. ## Activation **Activation** is how available an episode is right now. It is the sum of its traces, each faded by the square root of its age in real time: ``` activation = Σ strength of trace ÷ √(minutes since the trace) ``` A trace from a minute ago counts in full; one from four minutes ago counts half; one from a day ago counts about a fortieth. Time is wall-clock time, not message count: an agent left alone for a week comes back with a different sense of what is current than one that talked all week. ## Significance **Significance** is how much an episode has mattered overall: the plain sum of its traces, never faded. It is kept apart from activation on purpose. An episode can drop out of mind without becoming unimportant; its facts stay in long-term memory and recall can bring them back. ## Fade When the history reaches the **ceiling**, the least activated episodes leave first, until the view is down to the **landing**: about half the room, lower when this conversation's turns are large enough that two of them would not fit before the next fade. A live episode leaving is warned and its facts are asked for; once they are written it is **sealed**. A sealed episode leaving is **forgotten**. One fade finishes before the next begins. ## Sleep After 24 hours with no user message, the next user turn **sleeps** first: the view fades to the landing before the reply, whether or not it was full. The gap is accounted for before the agent answers, the way a night's sleep puts yesterday in perspective. ## Recall **Recall** brings back what the conversation is touching. For each new user message: - the message is split into its parts, so each subject it raises is searched on its own; - facts are searched by words and by meaning, for the whole message and for each part; - passages of sealed conversation are searched the same way; - a **walk** judges candidate facts for relevance to the conversation and follows the relevant ones to other facts about the same entities; - what survives is grouped by what it is about and dated, and placed just before the user message. After the reply, **distill** rereads the recall block against the turn and keeps only the facts that mattered, or removes the block when none did. --- # API reference > Every operation and schema of the runtime API, generated from openapi/runtime.yaml. Memory for agents. Call decorate before every model call and submit what it returns; report the reply after. Base URL: `https://api.threadlessai.com/v1`. Version 1.0.0. The raw spec is at [/openapi/runtime.yaml](https://threadlessai.com/openapi/runtime.yaml). Authenticate every request with a project key: `Authorization: Bearer tl_live_…` (or `tl_test_…`). Errors share one shape, [Error](#error), with a `hint` saying what to do about it. ## Operations | Operation | Method and path | Summary | |---|---|---| | [createMemory](#creatememory) | `POST /memories` | Create a memory: one per conversation. | | [getMemory](#getmemory) | `GET /memories/{memory_id}` | A memory's size and activity. | | [deleteMemory](#deletememory) | `DELETE /memories/{memory_id}` | Delete a memory and everything it holds. Cannot be undone. | | [decorate](#decorate) | `POST /memories/{memory_id}/decorate` | Before every model call: hand over what is new, get back the request to submit. | | [report](#report) | `POST /memories/{memory_id}/report` | After the model replies: hand over the reply. Returns at once. | | [exportMemory](#exportmemory) | `GET /memories/{memory_id}/export` | Everything a memory holds: messages, episodes and facts. | ### createMemory `POST https://api.threadlessai.com/v1/memories` Create a memory: one per conversation. Request body (optional): [CreateMemoryRequest](#creatememoryrequest) | Status | Meaning | Body | |---|---|---| | 201 | Created. | [Memory](#memory) | | 400 | The request is malformed. | [Error](#error) | | 401 | The API key is missing or revoked. | [Error](#error) | | 404 | No memory with this id in this project. | [Error](#error) | | 429 | Too many requests, or the plan quota is used up. | [Error](#error) | ### getMemory `GET https://api.threadlessai.com/v1/memories/{memory_id}` A memory's size and activity. | Parameter | In | Type | Required | |---|---|---|---| | `memory_id` | path | string `^mem_[A-Za-z0-9]{24}$` | yes | | Status | Meaning | Body | |---|---|---| | 200 | The memory. | [Memory](#memory) | | 400 | The request is malformed. | [Error](#error) | | 401 | The API key is missing or revoked. | [Error](#error) | | 404 | No memory with this id in this project. | [Error](#error) | | 429 | Too many requests, or the plan quota is used up. | [Error](#error) | ### deleteMemory `DELETE https://api.threadlessai.com/v1/memories/{memory_id}` Delete a memory and everything it holds. Cannot be undone. | Parameter | In | Type | Required | |---|---|---|---| | `memory_id` | path | string `^mem_[A-Za-z0-9]{24}$` | yes | | Status | Meaning | Body | |---|---|---| | 204 | Deleted and verified gone. | none | | 400 | The request is malformed. | [Error](#error) | | 401 | The API key is missing or revoked. | [Error](#error) | | 404 | No memory with this id in this project. | [Error](#error) | | 429 | Too many requests, or the plan quota is used up. | [Error](#error) | ### decorate `POST https://api.threadlessai.com/v1/memories/{memory_id}/decorate` Before every model call: hand over what is new, get back the request to submit. Returns the full request body for OpenAI: history with forgotten parts already out of view, remembered facts and recall in place, and the note ask when history is leaving. The `Server-Timing` header carries each step. | Parameter | In | Type | Required | |---|---|---|---| | `memory_id` | path | string `^mem_[A-Za-z0-9]{24}$` | yes | Request body: [DecorateRequest](#decoraterequest) | Status | Meaning | Body | |---|---|---| | 200 | The request to submit. | [DecorateResult](#decorateresult) | | 400 | The request is malformed. | [Error](#error) | | 401 | The API key is missing or revoked. | [Error](#error) | | 404 | No memory with this id in this project. | [Error](#error) | | 429 | Too many requests, or the plan quota is used up. | [Error](#error) | ### report `POST https://api.threadlessai.com/v1/memories/{memory_id}/report` After the model replies: hand over the reply. Returns at once. | Parameter | In | Type | Required | |---|---|---|---| | `memory_id` | path | string `^mem_[A-Za-z0-9]{24}$` | yes | Request body: [ReportRequest](#reportrequest) | Status | Meaning | Body | |---|---|---| | 202 | Accepted; the work runs in the background. | [ReportResult](#reportresult) | | 400 | The request is malformed. | [Error](#error) | | 401 | The API key is missing or revoked. | [Error](#error) | | 404 | No memory with this id in this project. | [Error](#error) | | 429 | Too many requests, or the plan quota is used up. | [Error](#error) | ### exportMemory `GET https://api.threadlessai.com/v1/memories/{memory_id}/export` Everything a memory holds: messages, episodes and facts. | Parameter | In | Type | Required | |---|---|---|---| | `memory_id` | path | string `^mem_[A-Za-z0-9]{24}$` | yes | | Status | Meaning | Body | |---|---|---| | 200 | The export. | [MemoryExport](#memoryexport) | | 400 | The request is malformed. | [Error](#error) | | 401 | The API key is missing or revoked. | [Error](#error) | | 404 | No memory with this id in this project. | [Error](#error) | | 429 | Too many requests, or the plan quota is used up. | [Error](#error) | ## Schemas ### Error | Field | Type | Required | Description | |---|---|---|---| | `error` | object | yes | | | `error.code` | `"invalid_request"` or `"unauthorized"` or `"forbidden"` or `"not_found"` or `"conflict"` or `"rate_limited"` or `"quota_exceeded"` or `"internal"` | yes | | | `error.message` | string | yes | | | `error.hint` | string | no | What to do about it. | ### CreateMemoryRequest | Field | Type | Required | Description | |---|---|---|---| | `metadata` | map of string, at most 512 | no | Up to 16 string pairs of your own, such as your conversation id. | ### DecorateRequest | Field | Type | Required | Description | |---|---|---|---| | `format` | `"chat.completions"` or `"responses"` | yes | The OpenAI API the request is for: Chat Completions (`messages`) or Responses (`input` items). | | `messages` | array of object or object | yes | Everything new since the last call: the reply plus the user message, or the reply plus tool results. System and developer messages are the instructions: the latest set replaces the stored one and always leads the request. | | `messages[]` | object | | A Chat Completions message, exactly as it would be sent to OpenAI. | | `messages[]` | object | | A Responses input item (message, function_call, function_call_output, reasoning, …). | | `request` | object | yes | The rest of the request body as it would be sent to OpenAI (model, tools, tool_choice, temperature, instructions, …), without the history. `previous_response_id` and `store` are dropped. | | `request.model` | string | yes | | ### DecorateResult | Field | Type | Required | Description | |---|---|---|---| | `request` | object | yes | The request body, ready to submit to OpenAI with your own key. | | `memory` | object | yes | | | `memory.id` | string `^mem_[A-Za-z0-9]{24}$` | yes | A memory id, as `POST /v1/memories` returned it. | | `memory.revision` | integer | yes | Increases with every change to the memory. | | `memory.turn` | integer | yes | The current user turn, counting from 1. | | `memory.notes_requested` | boolean | yes | True when this request asks the model for notes on history leaving live context. The model answers with the note tool. | | `memory.recalled` | integer | yes | Facts and passages recalled into this request. | | `memory.facts_in_view` | integer | yes | Remembered facts shown in this request. | | `memory.tokens` | integer | yes | Our estimate of the request size, in tokens. | ### Memory | Field | Type | Required | Description | |---|---|---|---| | `id` | string `^mem_[A-Za-z0-9]{24}$` | yes | A memory id, as `POST /v1/memories` returned it. | | `object` | `"memory"` | yes | | | `created_at` | string (date-time) | yes | | | `last_activity_at` | string (date-time) or null | yes | | | `metadata` | map of string | yes | | | `turns` | integer | yes | | | `messages` | integer | yes | | | `episodes` | object | yes | | | `episodes.live` | integer | yes | In live context, messages and all. | | `episodes.sealed` | integer | yes | Messages out of view; their facts still shown. | | `episodes.forgotten` | integer | yes | Out of view entirely; held in long-term memory for recall. | | `facts` | integer | yes | | | `bytes` | integer | yes | Stored size, the figure storage is billed on. | ### MemoryExport | Field | Type | Required | Description | |---|---|---|---| | `memory` | [Memory](#memory) | yes | | | `messages` | array of object | yes | | | `messages[].seq` | integer | yes | | | `messages[].turn` | integer | yes | | | `messages[].at` | string (date-time) | yes | | | `messages[].item` | object | yes | | | `episodes` | array of object | yes | | | `episodes[].id` | integer | yes | | | `episodes[].state` | `"live"` or `"leaving"` or `"sealed"` or `"forgotten"` | yes | | | `episodes[].first_turn` | integer | yes | | | `episodes[].last_turn` | integer | yes | | | `episodes[].encoded_at` | string (date-time) | yes | | | `episodes[].message_seqs` | array of integer | yes | | | `facts` | array of object | yes | | | `facts[].id` | string | yes | | | `facts[].text` | string | yes | | | `facts[].entities` | array of string | yes | | | `facts[].milestone` | boolean | yes | | | `facts[].written_by` | `"model"` or `"system"` | yes | | | `facts[].written_at` | string (date-time) | yes | | ### ReportRequest | Field | Type | Required | Description | |---|---|---|---| | `format` | `"chat.completions"` or `"responses"` | yes | The OpenAI API the request is for: Chat Completions (`messages`) or Responses (`input` items). | | `reply` | object | yes | The provider response, exactly as OpenAI returned it (usage included). | ### ReportResult | Field | Type | Required | Description | |---|---|---|---| | `accepted` | `true` | yes | | | `revision` | integer | yes | | --- # Admin MCP setup > Connect Claude Code, Cursor or Codex to the admin MCP server so your coding agent can create projects and API keys. The admin MCP server lets a coding agent (Claude Code, Cursor, Codex, or any MCP client) manage your Threadless AI account: create projects, create and revoke API keys, list memories and read usage. It works on infrastructure only. It can see a memory's size and activity, never its content. It is not part of the runtime. Your agent's memory is the context it runs in, not a tool it calls, so the runtime is `decorate` and `report` through the SDKs, and there is no runtime MCP server. ## Server | | | |---|---| | URL | `https://mcp.threadlessai.com/admin` | | Transport | Streamable HTTP | | Auth | OAuth 2.1; your client opens a browser to sign in and approve | ## Per-client setup Setup steps for Claude Code, Cursor, Codex and other clients are coming here. Until then, add `https://mcp.threadlessai.com/admin` as a remote MCP server in your client and approve the sign-in when it asks. You can always create keys by hand in the [console](https://threadlessai.com/app).