This is the full developer documentation for newt
# Agents that regrow their context.
> One long-lived agent per customer, with your backend as its world. One Rust binary plus Postgres.
Newts regrow what they lose. So does this: every turn strips the context back and grows it again from your app’s state and the history, so stale reads never pile up and the prompt cache stays warm.
## How it works
[Section titled “How it works”](#how-it-works)
1. **Your app posts an event.** “The person wrote”, “the order shipped”. newt answers in milliseconds and runs a turn.
2. **newt builds the prompt from your state.** Your app serves the agent’s instructions and the customer’s state as text pages. When a tool changes something, the page goes stale and the next read fetches it fresh.
3. **Tools act, history keeps what was done.** Reads are dropped at the end of the turn. Writes keep a summary. Messages and payments go through a journal, so a crash never sends twice.
## Why it holds up over months
[Section titled “Why it holds up over months”](#why-it-holds-up-over-months)
Append, never rewrite
Each turn’s prompt is the previous one plus a suffix, so the provider’s prefix cache covers everything before it.
Retention classes
Every tool says what survives the turn: `ephemeral` reads are dropped, `durable` writes keep a summary, `outward` effects are journaled.
A reactive workspace
Tools mark pages stale by key, and the next read fetches them again. The agent never acts on a copy it knows is out of date.
Exactly-once outward calls
An intent entry reaches Postgres before every outward call, and the call carries a stable idempotency key.
Compaction that keeps promises
The agent writes its own checkpoint. newt refuses one that drops an open commitment. Raw history is never deleted.
One binary plus Postgres
Leases, `LISTEN` wakeups and sweeps let any instance run any session. Bring your Postgres, or let newt run its own.
## Try it without an API key
[Section titled “Try it without an API key”](#try-it-without-an-api-key)
A fake model answers, so all you need is Docker.
```sh
docker compose -f deploy/compose.yaml up --build -d
curl -X POST localhost:7878/v1/agents/assistant-mcp/sessions/ana/events \
-H "Authorization: Bearer demo-token" -H 'content-type: application/json' \
-d '{"id":"m1","text":"Remind me to call the bank tomorrow."}'
curl -N localhost:7878/v1/agents/assistant-mcp/sessions/ana/live -H "Authorization: Bearer demo-token"
```
## Read next
[Section titled “Read next”](#read-next)
[Quickstart](quickstart/)Run the demo assistant and watch a turn live.
[How a turn works](how-a-turn-works/)From event to commit, and what survives.
[Context provider](context-provider/)The three endpoints your app serves.
[Tools over MCP](tools/)Tag a tool with what it leaves behind.
# What is newt
> An agent runtime for products. One long-lived agent per customer, your backend as its world, and a database's discipline about context.
newt runs one long-lived agent per customer of your product. Your app sends it events (“the person wrote”, “the order shipped”), and newt runs a turn: it reads your app’s state, calls your tools, and acts. A conversation can last for months without its prompt rotting.
It is one Rust binary plus Postgres.
## What you bring, what newt does
[Section titled “What you bring, what newt does”](#what-you-bring-what-newt-does)
| You bring | newt does |
| ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- |
| A **context provider**: three HTTP endpoints that return instructions and the customer’s state as text pages | Builds every prompt, keeps it cacheable, refetches pages when they change |
| **Tools** on an MCP server, each tagged with what it leaves behind | Runs the turn loop, decides what reaches history, sends outward calls exactly once |
| **Events**, posted when something happens | Queues, steers and schedules turns across any number of instances |
| A **model route**: Anthropic, OpenAI, or any Chat Completions host | Streams, retries, falls back along a chain, and reports every cache miss |
You can skip the context provider: an agent can be [nothing but an MCP server](../mcp-agents/).
## How the pieces connect
[Section titled “How the pieces connect”](#how-the-pieces-connect)
```text
events (POST) live feed (SSE)
your app ----------------------+ +--------------------------> your app
v |
+---------------------+
| newt-server |
| event API |
| runner: leases, |
| sweeps, turn loop |
+---------------------+
| | | |
model calls | | | | state, journal, wakeups
+------------------+ | | +----------------------+
v v v v
Model providers Context provider MCP servers Postgres
(Anthropic, OpenAI) (bootstrap, turn, (tools with (inbox, history,
pages) newt _meta) journal, leases)
```
## Why it holds up over months
[Section titled “Why it holds up over months”](#why-it-holds-up-over-months)
Most runtimes let the prompt grow and hope. That has two costs: stale tool results sit in the prompt and the model acts on them, and any edit to earlier text throws away the provider’s prompt cache. newt treats the prompt like a database treats a log:
* **Append, never rewrite.** Each turn’s prompt is the previous one plus a suffix, so the cache covers everything before it.
* **Keep what was done, not what was seen.** Reads are dropped at the end of a turn. The agent reads your current state again next time.
* **Rebuild from a source of truth.** Your app owns the workspace pages. When a tool changes something, the page goes stale and the next read fetches it fresh.
[How a turn works](../how-a-turn-works/) walks through one turn. [Design](../design/) has the full reasoning, with a pointer to the code for each claim.
## When newt fits
[Section titled “When newt fits”](#when-newt-fits)
newt fits a product where each customer has one agent that talks to them, or acts for them, over weeks: an assistant, a concierge, an account manager, a support agent with memory.
It leaves out, on purpose:
* memory shared across sessions;
* sandboxed code execution;
* multi-agent orchestration;
* a scheduler for timers. Your app sends the event when it is time.
## Status
[Section titled “Status”](#status)
newt is at **1.0**. Each release ships `newt` binaries for Linux (x86_64, arm64) and macOS (arm64) and a Docker image at `ghcr.io/julianbouchard/newt`. Building from source needs Rust 1.99. newt runs its own Postgres 18 or connects to yours.
| Maturity | What |
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Stable | The turn loop and retention classes, steering, the crash-safe journal, fenced leases and several instances on one Postgres, the events API and live feed, deleting sessions and pruning old rows, the context provider, MCP tools, approvals through MCP `input_required`, MCP tasks, agents made of one MCP server, compaction, prompt cache keys, OTLP tracing, embedded Postgres |
| Experimental | Skills over MCP (SEP-2640, a draft spec), replay and fork, the `openai-chat` protocol, the turn token budget, recitation (`recite` in the bootstrap, off by default) |
Experimental features work and are tested, including end to end against `newt-demo`, but the specs they follow or the shape of their settings may still change. [Stability](../stability/) lists what 1.x promises.
## Next
[Section titled “Next”](#next)
* [Quickstart](../quickstart/): run the demo assistant with Docker and no API key.
* [How a turn works](../how-a-turn-works/): the loop, step by step.
* [Context provider](../context-provider/): the three endpoints your app serves.
# Quickstart
> Run the newt demo assistant with Docker and no API key, send it a message, and watch the turn live.
`newt-demo` is a small assistant that keeps a person’s notes and tasks and replies by chat. One process serves both halves an app gives newt: the context provider and the tools over MCP. You need Docker, and nothing else: a fake model answers instead of a real one.
## 1. Start everything
[Section titled “1. Start everything”](#1-start-everything)
```sh
docker compose -f deploy/compose.yaml up --build -d
```
This runs Postgres, the demo app, the fake model from `newt-loadtest`, and newt on `localhost:7878`.
## 2. Send a message
[Section titled “2. Send a message”](#2-send-a-message)
```sh
curl -X POST localhost:7878/v1/agents/assistant-mcp/sessions/ana/events \
-H "Authorization: Bearer demo-token" -H 'content-type: application/json' \
-d '{"id":"m1","text":"Remind me to call the bank tomorrow."}'
```
`assistant-mcp` is the agent and `ana` is the session: one conversation with one person. newt answers `202` in a few milliseconds and runs the turn in the background.
## 3. Watch the turn
[Section titled “3. Watch the turn”](#3-watch-the-turn)
```sh
curl -N localhost:7878/v1/agents/assistant-mcp/sessions/ana/live \
-H "Authorization: Bearer demo-token"
```
The live feed is server-sent events: `received`, `turn_started`, a `tool_started` and `tool_finished` per tool call, then `turn_completed`. Frames carry names, counts and timings, never message text.
## What just happened
[Section titled “What just happened”](#what-just-happened)
1. newt stored the event, woke the session and claimed it.
2. It loaded the agent’s instructions, tools and the person’s pages (notes and tasks) from the demo’s MCP server.
3. The model read the workspace, added the task through a tool, sent a reply, and ended the turn.
4. newt committed the turn: the write and the reply stayed in history, the reads were dropped.
[How a turn works](../how-a-turn-works/) covers each step.
## Two flavours of the same agent
[Section titled “Two flavours of the same agent”](#two-flavours-of-the-same-agent)
The demo runs as two agents:
* `assistant` gets its instructions and pages from a [context provider](../context-provider/) (`/assistant`), and its tools from MCP (`/mcp`).
* `assistant-mcp` is [nothing but an MCP server](../mcp-agents/): instructions, tools, the person’s notes and tasks as resources, and a skill.
Its MCP server also shows the experimental flows: `clear_tasks` asks the person to approve before it deletes anything (MCP `input_required`), and `weekly_review` runs as an MCP task that newt polls until it is done.
Use a real model
Set `OPENAI_API_KEY` and point `[models.default]` in `deploy/server.toml` at the `openai` route.
## From source
[Section titled “From source”](#from-source)
newt runs its own Postgres in `.newt/postgres`, downloading the binaries once, so there is no database to set up.
```sh
export ASSISTANT_TOKEN=$(openssl rand -hex 16)
# terminal 1: the app side
cargo run -p newt-demo
# terminal 2: newt, with the route keys server.example.toml names
export OPENAI_API_KEY=... ANTHROPIC_API_KEY=...
NEWT_CONFIG=server.example.toml cargo run -p newt-server
```
Talk to it, watch the turn, and read what it sent:
```sh
curl -X POST localhost:7878/v1/agents/assistant/sessions/ana/events \
-H "Authorization: Bearer $ASSISTANT_TOKEN" -H 'content-type: application/json' \
-d '{"id":"m1","text":"Remind me to call the bank tomorrow. Also, I moved to Lisbon."}'
curl -N localhost:7878/v1/agents/assistant/sessions/ana/live -H "Authorization: Bearer $ASSISTANT_TOKEN"
curl localhost:4000/assistant/outbox/ana -H "Authorization: Bearer $ASSISTANT_TOKEN"
```
`newt-report newt-events.jsonl` breaks each turn down by model call. On a live run against `gpt-6.1-terra`, each turn took 6 to 8 seconds, of which newt itself spent about 20 ms.
## Next
[Section titled “Next”](#next)
* Copy `crates/newt-demo` to start your own app.
* [Context provider](../context-provider/): what your app serves.
* [Tools over MCP](../tools/): how a tool says what it leaves behind.
# How a turn works
> One turn from event to commit, the three layers of the prompt, and what survives into history.
A **session** is one conversation of one agent, named by a session key such as `ana`. A **turn** is one run of the model loop over the events that arrived for a session. Here is one turn, from the event to the commit.
## The prompt has three layers
[Section titled “The prompt has three layers”](#the-prompt-has-three-layers)
Every model call is built from the same three layers, in this order:
```text
frozen prefix instructions · pinned · skills index · tool list cached
history latest checkpoint · committed turns cached
scratch turn context · new events · replies · tool calls new
```
* The **frozen prefix** changes only when your app publishes a new bootstrap version, or the session’s `pinned` text changes.
* **History** only grows. A turn appends to it when it commits.
* **Scratch** is what the current turn adds. Most of it is thrown away at the end.
So each turn’s prompt is the previous one plus a suffix, and the provider’s prefix cache covers everything before the suffix.
## One turn, step by step
[Section titled “One turn, step by step”](#one-turn-step-by-step)
1. **An event arrives.** Your app posts it to `POST /v1/agents/{agent}/sessions/{key}/events`. newt stores it (deduplicated by its `id`), answers `202`, and wakes the session with a Postgres `NOTIFY`.
2. **A runner claims the session.** Any newt instance can. It takes a lease, so two instances never run the same session.
3. **newt asks your app for context.** It calls `POST {base}/turn` for this session’s pages and turn guidance, and `GET {base}/bootstrap` when the version changed. See [Context provider](../context-provider/).
4. **The model loop runs.** The model reads pages with `read_file`, `glob` and `grep`, and calls your [tools](../tools/). An event that arrives meanwhile joins the turn between model calls (steering).
5. **Tools mark pages stale.** A tool that changes a customer’s tasks invalidates the `tasks` page. The next read fetches it fresh.
6. **Outward calls go through the journal.** Before a tool that sends a message or charges a card runs, newt writes an intent entry to Postgres and waits for it. A crash mid-turn replays without sending twice.
7. **The turn commits.** Retention classes decide what moves from scratch into history.
8. **Compaction, if needed.** When live history passes the budget (60,000 tokens by default), the agent writes a checkpoint. See [Memory and compaction](../memory/).
## What survives a turn
[Section titled “What survives a turn”](#what-survives-a-turn)
Each tool declares a retention class. That class decides what history keeps.
| Class | What history keeps | For |
| ----------- | ------------------------------------------------------- | -------------------------- |
| `ephemeral` | Nothing: call and result are dropped | Reads and lookups |
| `durable` | The call, with the result cut to its `newt/summary` | Writes to your own system |
| `outward` | Same as `durable`, plus journaled exactly-once handling | Messages, emails, payments |
The model’s text replies are kept and its reasoning is dropped. History carries what the agent did, not what it saw: anything it learned from a read, it can read again from the workspace.
## Where to put guidance
[Section titled “Where to put guidance”](#where-to-put-guidance)
Your app has three places to put text, and each has a different cache cost.
| Field | Lives in | Cache cost |
| ---------------------------------- | ---------------------------- | ---------------------------------------------- |
| bootstrap `instructions` | Frozen prefix, every session | One miss per new version |
| turn `pinned` | Frozen prefix, this session | Free while unchanged, one miss when it changes |
| turn `instructions`, `constraints` | Scratch, this turn only | Free, never enters history |
## Next
[Section titled “Next”](#next)
* [Design](../design/) explains why each piece works this way, with pointers to the code.
* [Tools over MCP](../tools/) shows how to tag a tool.
# Context provider
> The three JSON endpoints your app serves so newt can build each prompt from the customer's current state.
The context provider is how your app tells newt what the agent is and what it can see. It is three JSON endpoints under one base URL. newt calls them; your app never calls newt back.
```toml
[agents.assistant]
context_provider = "https://app.example.com/assistant"
token_env = "ASSISTANT_TOKEN"
```
One token authenticates both directions: callers send it to newt as `Authorization: Bearer`, and newt sends it to your provider.
Tip
An agent can skip the context provider and be [only an MCP server](../mcp-agents/).
## The three endpoints
[Section titled “The three endpoints”](#the-three-endpoints)
| Call | When newt makes it | Returns |
| ---------------------- | ------------------------------------------------------------------- | -------------------------------------------- |
| `GET {base}/bootstrap` | First turn, then whenever `version` changes | Instructions, model, skills, MCP servers |
| `POST {base}/turn` | Start of every turn | The session’s pages and this turn’s guidance |
| `POST {base}/pages` | A read of a stale page, or the first read when `turn` sent no pages | Fresh pages |
## Bootstrap: the same for every session
[Section titled “Bootstrap: the same for every session”](#bootstrap-the-same-for-every-session)
```json
{
"version": "2026-11-12.1",
"instructions": "You are a personal assistant for one person, reached by chat.",
"model": "default",
"skills": [
{ "name": "writing", "description": "How to word a message to the person.", "content": "Short sentences. Lead with the answer." }
],
"mcp_servers": [
{ "name": "app", "url": "https://app.example.com/mcp", "auth": "agent_token" }
]
}
```
`model` is a `[models]` alias from `server.toml`. Changing any field means a new `version`, which costs one prompt cache miss on the next turn. Optional knobs (`effort`, `max_model_calls`, `history_budget` and more) are in [Protocol 1.3](../protocol/#13-get-basebootstrap).
## Turn: this session, right now
[Section titled “Turn: this session, right now”](#turn-this-session-right-now)
```json
{
"version": "2026-11-12.1",
"pages": [
{ "key": ["tasks"], "path": "tasks.md", "version": "7", "content": "# Tasks\n- [ ] #1 Call the bank\n" },
{ "key": ["notes"], "path": "notes.md", "version": "7", "content": "# Notes\n- Lives in Lisbon\n" }
],
"pinned": "Write for WhatsApp: no markdown, two short paragraphs at most.",
"instructions": "It is 2026-11-12T09:30:00Z.",
"constraints": ["Do not message the person before Monday: they are on holiday."]
}
```
* `pinned` is session-stable text, such as the writing guide for the person’s channel. It sits in the cached prefix.
* `instructions` and `constraints` reach the model at the start of this turn only and never enter history.
Caution
Constraints are text the model reads. They do not block a tool call. Enforce any rule that must hold in your tool handlers too.
## Pages: the workspace
[Section titled “Pages: the workspace”](#pages-the-workspace)
A page is one file the agent can read with `read_file`, `glob` and `grep`.
| Field | Meaning |
| --------- | ------------------------------------------------------------------------------------------ |
| `key` | Invalidation key, such as `["customer", "profile"]`. Tools mark pages stale by key prefix. |
| `path` | What the agent sees, such as `profile.md`. Unique per response. |
| `version` | Opaque. newt tells tools which version the agent read. |
| `content` | The text. |
Pages are rebuilt from your app, never edited by newt. When a tool marks a page stale, the next read calls `POST {base}/pages` with that key, and a page missing from the answer is gone.
**Slow pages?** Leave `pages` out of the turn response. newt starts the first model call at once and loads every page in the background. The first read waits for that load.
## Rules newt enforces
[Section titled “Rules newt enforces”](#rules-newt-enforces)
* Each request times out after 10 seconds. newt does not retry inside a request.
* Decoding is strict: unknown fields, an unknown model alias or a version mismatch fail the turn loudly.
* A failed call stops the turn from starting. The events stay pending and the session retries with backoff (2 s doubling, up to 300 s).
## Next
[Section titled “Next”](#next)
* [Tools over MCP](../tools/): the other half your app serves.
* [Protocol, section 1](../protocol/#1-context-provider): every field and rule.
# Tools over MCP
> Serve tools from MCP servers and tag each one with what it leaves in history, which pages it changes, and whether it acts outside your system.
Tools come from the MCP servers your bootstrap lists. Any MCP server works as is. A few `_meta` fields let a tool tell newt what it leaves behind, and that is what keeps long sessions clean.
```json
{ "name": "app", "url": "https://app.example.com/mcp", "auth": "agent_token" }
```
`auth: "agent_token"` sends the agent’s token as the bearer. The default sends none. `headers` are static and must not hold secrets. newt speaks Streamable HTTP, MCP `2026-07-28` with a fallback to `2025-11-25`, and shares one connection per server across every session of a bootstrap version.
## Tag each tool
[Section titled “Tag each tool”](#tag-each-tool)
Three optional fields on the tool, in `tools/list`:
| Field | Meaning |
| ------------------ | -------------------------------------------------------------------------- |
| `newt/retention` | `ephemeral`, `durable` (default) or `outward`. What reaches history. |
| `newt/invalidates` | Page key prefixes that every successful call makes stale. |
| `newt/idempotent` | Must be `true` on an `outward` tool, or newt refuses to load the tool set. |
```json
{
"name": "complete_task",
"description": "Mark a task done by its number.",
"inputSchema": { "type": "object", "properties": { "id": { "type": "integer" } }, "required": ["id"] },
"_meta": { "newt/retention": "durable", "newt/invalidates": [["tasks"]] }
}
```
## Pick a retention class
[Section titled “Pick a retention class”](#pick-a-retention-class)
| Class | Use it for | History keeps |
| ----------- | ------------------------------------------------------- | --------------------------------------------------------------------- |
| `ephemeral` | Reads and lookups | Nothing |
| `durable` | Writes to your own system | The call and its `newt/summary` (or the result cut to 500 characters) |
| `outward` | Messages, emails, payments: effects outside your system | Same as `durable`, and the call is journaled before it runs |
A server that ignores these fields still works: its tools count as `durable` and invalidate nothing.
## Make outward calls exactly once
[Section titled “Make outward calls exactly once”](#make-outward-calls-exactly-once)
A turn can send a message and crash before it commits. newt makes sure the retry carries the same key; your handler makes sure the effect happens once.
1. newt writes an intent entry to Postgres and waits for it before the call. If that write fails, the call is not made.
2. Every call carries `newt/idempotency_key`, stable across retries and replays.
3. Your handler stores the key in the same transaction as the effect. On a repeated key it returns the original result and does nothing else.
Outward calls run after the other calls of a model step, one at a time. If one fails, the later ones in that step are skipped. The reference handler is `send_message` in `crates/newt-demo/src/tools.rs`.
## What newt sends with every call
[Section titled “What newt sends with every call”](#what-newt-sends-with-every-call)
| Field in `params._meta` | Use it to |
| ----------------------- | ------------------------------------------------------------- |
| `newt/session` | Know which customer this is |
| `newt/idempotency_key` | Deduplicate effects |
| `newt/skills_loaded` | Refuse an action until the right [skill](../skills/) was read |
| `newt/pages_read` | Refuse to act on a page that changed since the agent read it |
| `traceparent` | Parent your own spans under the turn’s trace |
## What a result can send back
[Section titled “What a result can send back”](#what-a-result-can-send-back)
| Field in the result’s `_meta` | Effect |
| ----------------------------- | --------------------------------------------- |
| `newt/summary` | What history keeps instead of the full result |
| `newt/invalidate` | More page key prefixes to mark stale |
| `newt/pages` | Fresh page data, used without a refetch |
Invalidation only reaches pages newt already has, so a tool that creates a page returns it in `newt/pages`. A result with malformed newt metadata marks every page stale.
## Timeouts and unknown outcomes
[Section titled “Timeouts and unknown outcomes”](#timeouts-and-unknown-outcomes)
A call may go 90 seconds without a result or a progress notification, and 10 minutes in total. Past either, or on a broken connection, the model is told the outcome is unknown and to check the workspace before trying again. Report `notifications/progress` to keep a long call alive; each one also reaches the live feed as `tool_progress`.
## Approvals and long-running tools
[Section titled “Approvals and long-running tools”](#approvals-and-long-running-tools)
* **Ask first.** A tool that answers MCP `input_required` with an elicitation becomes a question to the person. The model asks, then calls the tool again with the answers. Nothing is stored in between.
* **Run as a task.** A call the server answers with an MCP task is polled for up to an hour.
## Names newt keeps
[Section titled “Names newt keeps”](#names-newt-keeps)
Your tools cannot use the built-in names: `read_file`, `glob`, `grep`, `end_turn`, `remember`, `batch`, `checkpoint`, `load_skill`. newt does not validate arguments against `inputSchema`; validate in the handler.
## Next
[Section titled “Next”](#next)
* [Protocol, sections 2 to 4](../protocol/#2-mcp-tools): every field and rule.
* [An agent that is only an MCP server](../mcp-agents/).
# This page didn't regrow.
> Newts grow back what they lose. This URL is gone for good, or never was.
# Configuration
> Newt reads one TOML file.
Newt reads one TOML file. The path is the `NEWT_CONFIG` environment variable, or `server.toml` in the working directory. `server.example.toml` in the repository root is a complete working example; a test parses it, so it stays valid.
Every table rejects unknown keys. Secrets are never in the file: the file holds the names of environment variables, and newt refuses to start if one is unset. A bad file fails at startup with a message naming the key.
Other environment variables:
| Variable | Default | Meaning |
| ------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `NEWT_CONFIG` | `server.toml` | Path to the config file. |
| `RUST_LOG` | `info` | Log filter (`tracing` env-filter syntax). |
| `HOSTNAME` | `newt` | Prefix of the runner’s lease owner id, which is `{HOSTNAME}-{pid}-{uuid}`. Shows up as `lease_owner` in the session status. |
At startup newt connects to Postgres and applies the files in `migrations/`, then starts listening.
## `[server]`
[Section titled “\[server\]”](#server)
| Key | Type | Default | Meaning |
| -------- | -------------- | -------- | -------------------------------------------------------------------------------------------------- |
| `listen` | socket address | required | Address the HTTP API binds to, for example `"127.0.0.1:7878"`. Plain HTTP; terminate TLS in front. |
## `[database]`
[Section titled “\[database\]”](#database)
| Key | Type | Default | Meaning |
| ----------------- | ------- | ------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `url_env` | string | none | Name of the environment variable that holds the Postgres connection URL. |
| `embedded` | path | none | Run newt’s own Postgres with its data in this directory instead. |
| `max_connections` | integer | `10` | Size of the connection pool. newt opens every connection at startup, so a burst of sessions does not wait for the pool to grow. |
Set exactly one of `url_env` and `embedded`. With `embedded`, newt downloads the Postgres 18 binaries for this platform once (zonky’s builds from Maven Central, which bring their own libraries; into `~/.theseus/postgresql`, checked against their published hash), creates the database in `/data` on the first start, runs the server on a free local port while newt runs and stops it on exit. A server left running by a newt that was killed is stopped and restarted. It is meant for one machine: several newt instances share a Postgres you run, through `url_env`. Postgres refuses to run as root, so neither does `embedded`; the Docker setup in `deploy/` uses its own Postgres container. Builds without the default `embedded-postgres` feature reject `embedded`.
The pool serves event accepts, turn starts and commits, journal writes, the sweeper and live feeds. Accepting an event takes one connection and starting a turn three, so under a burst (hundreds of sessions receiving events in the same second) turns wait for the pool, not the model. Size it for your bursts: roughly one connection per session you expect to start in the same second, up to what your Postgres allows (`max_connections` there is 100 by default, shared by every newt instance).
## `[events]`
[Section titled “\[events\]”](#events)
Optional. The JSONL event file, used for local runs, sims and `newt-report`.
| Key | Type | Default | Meaning |
| ----------------- | ------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `jsonl` | path | none | Append one JSON line per event to this file. No file is written when it is unset. |
| `capture_content` | boolean | `false` | Keep message content (model input and output) in the file. When `false`, content is removed and only fingerprints, sizes, timings and usage remain. |
## `[runner]`
[Section titled “\[runner\]”](#runner)
| Key | Type | Default | Meaning |
| ------------------- | -------------------------------- | -------- | ---------------------------------------------------------------------- |
| `max_sessions` | integer, at least 1 | `64` | Sessions this process runs turns for at once. Others wait. |
| `cache_key_buckets` | integer, at least 1, or `"auto"` | `"auto"` | How many prompt cache keys the sessions of one agent share. See below. |
### `cache_key_buckets`
[Section titled “cache_key_buckets”](#cache_key_buckets)
For `openai-responses` routes newt sends `prompt_cache_key = "{agent}/{n}"`. Anthropic routes do not use it.
Sessions of one agent share the same system prompt and tools. With one key, a brand-new session can reuse the cached system prompt that every other session has already written, so its first call is not a full miss. The cost: OpenAI spreads one key across more machines once it receives more than roughly 15 requests a minute, and a request that lands on another machine misses the cache. So each key should carry as much traffic as it can without passing that rate.
With `"auto"` newt counts each agent’s model calls over the last minute and keeps every key under 12 calls a minute: it adds keys as soon as the rate needs them, up to 256 (about 3,000 calls a minute), and gives them up only after the rate has stayed low for 10 minutes, so a quiet minute does not reshuffle sessions. An agent starts with one key. A number fixes the count instead. Either way `n` comes from the session key by jump consistent hashing: a session keeps its key while the count holds, and going from n to n + 1 keys moves only one session in n + 1. A session that used its key in the last 10 minutes, while the provider still holds its prefix, keeps that key when keys are added, unless the key itself passed 12 calls in the last minute: new, idle and crowded sessions take their hashed key, and no other session moves.
### Fixed runner settings
[Section titled “Fixed runner settings”](#fixed-runner-settings)
These are constants in `RunnerConfig::default()` (`crates/newt-server/src/runner.rs`), not config keys:
| Setting | Value |
| --------------------------------------------------------- | ------------------------------------------------------------------------------- |
| Lease TTL | 30 s. A heartbeat renews running leases every 10 s. |
| Sweep interval | 1 s. Each sweep wakes up to 100 sessions with pending events and no live lease. |
| Retry backoff after a failed turn | 2 s doubling per failed attempt, capped at 300 s. |
| Attempts before dead-lettering a turn that had no effects | 5 |
| Shutdown grace period | 8 s, then running turns are cancelled and given 5 s more. |
## `[retention]`
[Section titled “\[retention\]”](#retention)
Optional. newt deletes rows that only serve operations once they are old. History and checkpoints are the agent’s memory and are never deleted on their own: delete a whole session with `DELETE /v1/agents/{agent}/sessions/{key}` ([Protocol](../protocol/), 5.6).
| Key | Type | Default | Meaning |
| ------------ | ------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `inbox_days` | integer | `30` | Keep events this many days after the turn that consumed them committed, dead-lettered ones included. A repeat of an event id is recognised for as long as the event is kept. `0` keeps them forever. |
| `live_days` | integer | `7` | Keep live feed frames this many days. `0` keeps them forever. |
Each instance prunes once at start and then every hour, in batches of 5,000 rows.
## `[routes.]`
[Section titled “\[routes.\\]”](#routesname)
A route is one way to reach one model.
| Key | Type | Default | Meaning |
| ---------------- | --------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `protocol` | `"anthropic-messages"`, `"openai-responses"` or `"openai-chat"` | required | The wire protocol. `openai-chat` is Chat Completions, which vLLM, Ollama, OpenRouter, Together, Groq and most other hosts speak; it carries no reasoning between calls and sends no `prompt_cache_key`. |
| `model` | string | required | The provider’s model id, sent in the request. |
| `base_url` | string | required | Must start with `http://` or `https://`. Newt appends `/v1/messages` for `anthropic-messages` (so use `https://api.anthropic.com`), `/responses` for `openai-responses` (so use `https://api.openai.com/v1`) and `/chat/completions` for `openai-chat` (for example `http://localhost:11434/v1` for Ollama, `https://openrouter.ai/api/v1` for OpenRouter). |
| `key_env` | string | required | Name of the environment variable that holds the API key. |
| `api_key_header` | string | none | Header that carries the key. By default `anthropic-messages` sends `x-api-key` and `openai-responses` sends `Authorization: Bearer`. Set it for gateways that use another header, such as `api-key`. |
| `max_concurrent` | integer, at least 1 | required | Model calls in flight on this route. Further calls wait. Calls from background-priority turns may use at most `max_concurrent - 1` of the slots (at least 1), which keeps a slot free for interactive turns. |
Anthropic requests also send `anthropic-version: 2023-06-01`. Connect timeout is 10 s, a stream that goes silent for 300 s fails, and any one model call has a 10 minute deadline. These are constants.
## `[models.]`
[Section titled “\[models.\\]”](#modelsalias)
A model alias is the name a bootstrap uses in its `model` field.
| Key | Type | Default | Meaning |
| ------- | -------------------- | -------- | --------------------------------------------------------------------------- |
| `chain` | array of route names | required | Routes to try, in order. Must be non-empty, and every name must be a route. |
Each call tries the first route; a retryable failure (a network error, a timeout or deadline, HTTP 408, 409, 429 or 5xx, or an `overloaded`, `api`, `rate_limit` or `server` error from the provider) that happens before any output has streamed gets one more try on the same route after a 200 ms pause, then moves to the next route. A failure after output has started streaming is not retried, because the live feed has already shown it.
## `[agents.]`
[Section titled “\[agents.\\]”](#agentsname)
An agent is the name in the event API path (`/v1/agents/{name}/...`).
| Key | Type | Default | Meaning |
| ------------------ | ------ | -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `context_provider` | string | one of `context_provider` and `mcp` | Base URL of the agent’s context provider; must start with `http://` or `https://`. See [Protocol](../protocol/). |
| `mcp` | string | one of `context_provider` and `mcp` | Streamable HTTP URL of the one MCP server the agent is made of: its instructions, tools, skills and resources ([Protocol](../protocol/) 2.7). Must start with `http://` or `https://`. |
| `model` | string | required with `mcp`, not allowed with `context_provider` | A `[models]` alias. A context provider names its model in the bootstrap instead. |
| `token_env` | string | required | Name of the environment variable that holds the agent’s token. It must be set and non-empty. The same token authenticates callers of the event API, and newt sends it to the context provider, to MCP servers that ask for `auth: "agent_token"`, and to an `mcp` agent’s server. |
## `[tracing]`
[Section titled “\[tracing\]”](#tracing)
Optional. Without this table, no traces are exported. With it, newt exports one trace per turn over OTLP/HTTP as JSON.
| Key | Type | Default | Meaning |
| ----------------- | --------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `endpoint` | string | required | OTLP traces URL; must start with `http://` or `https://`. |
| `profile` | `"generic"` or `"langfuse"` | `"generic"` | `"langfuse"` also sets Langfuse’s session, observation types and usage details. |
| `headers_env` | string | none | Name of an environment variable holding request headers as `name=value,name=value`. For Langfuse: `Authorization=Basic ,x-langfuse-ingestion-version=4`. Must be set if named. |
| `capture_content` | boolean | `true` | Export the messages new to each model call and its response. When `false`, only timings, token counts and tool names are exported, the session key in `gen_ai.conversation.id` is hashed, and error text becomes `failed`. Note the default differs from `[events] capture_content`. |
| `service_name` | string | `"newt"` | The `service.name` resource attribute. |
Span ids derive from the session, turn and call, so exporting the same turn twice overwrites rather than duplicates. Export runs off the turn’s path and drops events when it falls behind.
## Example
[Section titled “Example”](#example)
```toml
server = { listen = "127.0.0.1:7878" }
database = { url_env = "NEWT_DATABASE_URL", max_connections = 10 }
events = { jsonl = "newt-events.jsonl" }
runner = { max_sessions = 64, cache_key_buckets = "auto" }
retention = { inbox_days = 30, live_days = 7 }
[routes.openai]
protocol = "openai-responses"
model = "gpt-6.1-terra"
base_url = "https://api.openai.com/v1"
key_env = "OPENAI_API_KEY"
max_concurrent = 6
[routes.anthropic]
protocol = "anthropic-messages"
model = "claude-opus-5-5"
base_url = "https://api.anthropic.com"
key_env = "ANTHROPIC_API_KEY"
max_concurrent = 6
[models.default]
chain = ["openai", "anthropic"]
[agents.assistant]
context_provider = "http://localhost:4000/assistant"
token_env = "ASSISTANT_TOKEN"
[agents.tasks]
mcp = "http://localhost:4000/tasks/mcp"
model = "default"
token_env = "TASKS_TOKEN"
[tracing]
endpoint = "https://cloud.langfuse.com/api/public/otel/v1/traces"
profile = "langfuse"
headers_env = "NEWT_OTLP_HEADERS"
capture_content = true
```
# Design
> This is the reasoning behind newt, with a pointer to the code for each claim.
This is the reasoning behind newt, with a pointer to the code for each claim. If a claim here has no pointer, treat it as opinion.
The premise: an agent that talks to one person over weeks accumulates context, and most agent runtimes handle that by letting the prompt grow and hoping. That has two costs. Stale tool results sit in the prompt and the model acts on them. And any edit to earlier text invalidates the provider’s prompt cache, so every turn pays for the whole prompt again. Newt treats the prompt the way a database treats a log: append, never rewrite, and rebuild the working view from a source of truth.
## The prompt has three layers
[Section titled “The prompt has three layers”](#the-prompt-has-three-layers)
Each model call is built from three layers, in this order (`crates/newt-core/src/prompt.rs`):
1. **Frozen prefix.** The system prompt (the bootstrap’s `instructions`, then the session’s `pinned` text, then the skills index) and the tool list. It changes only when the application publishes a new bootstrap version, or when `pinned` changes.
2. **History.** The committed messages of the session, oldest first, preceded by the latest checkpoint if there is one.
3. **Scratch.** What the current turn adds: the turn context (`instructions` and `constraints`), the new events, the model’s replies and the tool calls and results so far.
Within a turn, a model call’s input extends the previous call’s input and nothing earlier changes. Between turns, the turn’s scratch is filtered and appended to history; earlier history is untouched. The next turn’s prompt is therefore the previous one plus a suffix. The provider’s prefix cache keys on exact prefixes, so the whole earlier prompt is a cache hit and only the suffix is new.
The code helps keep this true:
* Each model call emits three fingerprints, one per layer (`LayerFingerprint`: hash, bytes, items). The prefix and history fingerprints also carry one hash per piece: the system prompt and each tool, and each history message. `newt-report` reads them from the event file and prints a cache report card: the cached share of all input, the cost at given prices, and for every call that read under 50% from cache, what broke it. The causes are a first call, a changed model, the first changed piece of the prefix (`tool \`search\` changed, about 6120 bytes into the prefix\`, or a tool added, removed or reordered), history rewritten from message n (and whether compaction did it), an idle gap longer than the cache lives, or, with prefix and history unchanged and no gap, routing to a machine without the cache.
* For Anthropic, newt marks cache breakpoints on the system block, the last tool, the last history message and the last message (`provider/anthropic.rs`). For OpenAI it sends a `prompt_cache_key` (`provider/openai.rs`); see `[runner] cache_key_buckets` in [Configuration](../configuration/) for why sessions of one agent share a few keys.
* Recitation, off unless the bootstrap sets `recite`: on a long turn the model is reminded what it is answering. From the fifth model call, and every fourth after, the harness appends a `` note quoting the turn’s events (each cut to 500 characters). Like every harness note it lives in the turn’s scratch, after everything cached, and never reaches history. It is off by default because it did not pay: in `examples/recitation_eval.rs` (50 pairs of turns of up to 25 model calls on `gpt-6.1-terra`) every turn passed with and without it, and it cost 1 to 12% more input tokens.
* Per-turn guidance goes in scratch, not in the prefix, so it costs nothing to change. Text that is stable for a session goes in `pinned`, which sits in the prefix: free while unchanged, one miss when it changes.
The honest exceptions are a new bootstrap version (one miss on the prefix) and compaction, which replaces the older part of history once per budget crossing (below).
## Retention classes
[Section titled “Retention classes”](#retention-classes)
A turn’s scratch is full of material that was useful once: file reads, lookups, search results, the model’s reasoning. Keeping it makes history grow and goes stale. Newt decides what survives per tool, using a class the tool declares (`crates/newt-core/src/commit.rs`, `tools.rs`):
* `ephemeral`: the call and its result are dropped when the turn commits. Reads and lookups.
* `durable`: the call stays, and its result is replaced by a short `newt/summary` from the tool (or cut to 500 characters). Writes to the application’s own state.
* `outward`: kept like `durable`, plus the exactly-once handling below. Anything with an effect outside the application.
The model’s own text replies are kept. Its reasoning is dropped. What the agent learned from an ephemeral read is expected to be re-readable from the workspace, so history carries what was done, not what was seen. The agent can also save a fact explicitly with the built-in `remember` tool, which is `durable`.
## The reactive workspace
[Section titled “The reactive workspace”](#the-reactive-workspace)
The workspace is a set of text pages the application owns and the agent reads through `read_file`, `glob` and `grep` (`crates/newt-core/src/workspace.rs`). The context provider hands newt the pages at the start of a turn. A page has a key (an array of strings), a path and a version.
When a tool changes the application’s state, it says which pages that makes stale, by key prefix, either in its definition (`newt/invalidates`) or in the result (`newt/invalidate`). The next read of a stale page refetches it from the provider, so the agent never reads a copy it knows is out of date. A tool that knows the new content can return the page itself (`newt/pages`) and skip the refetch. Pages the agent has already read are refreshed in the background so the next read is quick.
Because reads are ephemeral and the workspace is current, nothing a read returned ever needs to be remembered by history. The workspace is what makes that safe. The cost is that the provider must be able to render pages on request; for slow renders, newt can start the first model call while pages load (lazy loading).
Tools are also told what the agent has seen: the pages read this turn with their versions, and the skills loaded. A handler can use this to refuse an action based on a page that has changed since the agent read it.
## Compaction, checkpoints and commitments
[Section titled “Compaction, checkpoints and commitments”](#compaction-checkpoints-and-commitments)
History cannot grow forever. After a turn commits, if the session’s live history is over `history_budget` tokens (60,000 by default, set in the bootstrap), newt compacts it (`crates/newt-server/src/compaction.rs`, `checkpoints.rs`).
Compaction is a turn like any other, run by the agent’s own model with the same prefix and history, plus one instruction. In it the agent first saves anything not yet recorded, then calls `checkpoint` with its memory, open threads, commitments, decisions and tone. Nothing is sent while compacting: outward tools are refused.
The risk in summarising is silent loss of a promise. Newt refuses a checkpoint that drops a commitment from the previous checkpoint unless it is listed under `resolved`, and it refuses an empty one or one longer than the budget’s character allowance (`checkpoint.rs`). The agent gets the refusal as a tool result and can try again within the turn’s model-call limit.
On success, newt stores the checkpoint and the sequence number it covers. Later prompts start with the rendered checkpoint, then the most recent whole turns that fit in a quarter of the budget, then anything saved during the flush. Raw history rows are never deleted; the checkpoint is a cut point, not a replacement. A failed compaction waits before the next try (ten minutes, doubling, at most a day) and the session keeps running on its long history meanwhile.
## The journal and exactly-once outward calls
[Section titled “The journal and exactly-once outward calls”](#the-journal-and-exactly-once-outward-calls)
A turn can send a message and then crash before it commits. If the session simply reruns, the person gets two messages. Newt avoids this with a per-turn journal in Postgres (`crates/newt-core/src/journal.rs`, `crates/newt-server/src/journal_store.rs`).
The journal records, in order: events that steered the turn, each model response, an intent entry before each outward call, and each tool output. After a crash another runner picks up the session, finds the open turn, and replays it: recorded model responses are used instead of calling the model, recorded tool outputs are used instead of calling the tools, and a call with no recorded output is made again.
That last case is the dangerous one, so outward calls are handled differently from other tools:
* The intent entry is written, and acknowledged by Postgres, before the call. If the write fails, the call is not made.
* Every call carries an idempotency key built from the session, turn id and call id. The turn id and call ids come from the journaled response, so a replayed call has the same key.
* Newt cannot see inside the application. It guarantees the retry carries the same key; the tool’s handler must store the key with the effect and treat a repeat as a no-op. A tool that does not declare `newt/idempotent: true` is refused at load time.
* Outward calls run last in a step, one at a time, at most one per `batch`. If one fails, later ones are skipped.
Model responses, steered events and tool outputs are appended without waiting for a durable commit (`synchronous_commit` is off for a batch that carries no intent entry); losing one means a model call or a safe tool call is made again. Only the intent entry waits, and its batch is committed durably. So the journal costs a synchronous wait only before outward calls, in addition to the reads at turn start and the commit at the end.
What this does not give you: exactly-once effects in a system whose handler ignores the key. It also does not make the model’s choices deterministic; replay avoids asking again by reusing what was recorded.
## The Postgres runner
[Section titled “The Postgres runner”](#the-postgres-runner)
Newt is one binary plus Postgres. A session’s state, its inbox of unprocessed events, history, checkpoints and the journal all live in Postgres (`migrations/0001_init.sql`), so any instance can run any session.
* **Leases.** A runner claims a session by setting `lease_owner`, bumping `lease_epoch` and a 30 second expiry in one `UPDATE`. A heartbeat renews the leases of running sessions every 10 seconds. Commits take the session row `FOR UPDATE` and check owner and epoch: a runner whose lease has moved gets `Fenced` and its commit is discarded. Journal writes are fenced the same way (`sessions.rs`, `journal_store.rs`).
* **Wakeups.** Accepting an event is one SQL statement that inserts it (deduplicated by event id), creates the session row if needed, and calls `pg_notify` on `newt_inbox`. Each instance holds a `LISTEN` connection and wakes the named session at once (`live.rs`, `runner.rs`).
* **Sweeps.** `NOTIFY` is not durable, so every second each instance runs a sweep that lists sessions with unconsumed events and no live lease, and wakes them. This covers missed notifications, crashed owners (their leases expire) and sessions in retry backoff when it ends.
* **Retries.** A turn that fails without effects is retried with backoff and dead-lettered after five attempts; one that already had effects commits what it did and is retried for the rest.
* **Steering.** Events that arrive during a turn join it between model calls, and interrupt a model call that has not started producing output (`steering.rs`, `turn.rs`).
* **Live feed.** Turn progress is appended to a per-session table and followed over SSE, with `NOTIFY` to wake other instances.
## Not in the design
[Section titled “Not in the design”](#not-in-the-design)
No cross-session memory, no sandboxed code execution, no multi-agent orchestration, no scheduler for timers. The crates are not published to crates.io. What 1.x keeps stable is listed on the [Stability](../stability/) page.
# Sending events
> Post events to a session, follow turns on the live feed, read history, and delete a session.
Your app talks to newt through a small HTTP API. Agent and session key are in the path, and the agent’s token is the bearer.
| Method and path | Purpose |
| ----------------------------------------------- | ----------------------------------------------- |
| `POST /v1/agents/{agent}/sessions/{key}/events` | Add an event |
| `GET /v1/agents/{agent}/sessions/{key}` | Session status |
| `GET /v1/agents/{agent}/sessions/{key}/live` | Live feed (server-sent events) |
| `GET /v1/agents/{agent}/sessions/{key}/history` | What history kept |
| `POST /v1/agents/{agent}/sessions/{key}/fork` | A new session with a copy of this one’s history |
| `DELETE /v1/agents/{agent}/sessions/{key}` | Delete the session and everything stored for it |
| `GET /healthz` | 200 when Postgres answers. No auth. |
A session key is 1 to 200 bytes with no `/`. A session starts with its first event.
## Post an event
[Section titled “Post an event”](#post-an-event)
```json
{ "id": "m1", "text": "Remind me to call the bank tomorrow.", "policy": "steer", "priority": "interactive" }
```
| Field | Default | Meaning |
| ---------- | ------------- | ----------------------------------------------------------------------------------------------- |
| `id` | required | Your id for the event. A repeat in the same session is accepted and not queued again. |
| `text` | required | What the agent reads. |
| `policy` | `steer` | `steer` joins a running turn between model calls. `queue` and `collect` wait for the next turn. |
| `priority` | `interactive` | `background` turns leave a model slot free for interactive ones. |
| `meta` | none | Any JSON, stored with the event, never shown to the model. |
newt answers `202` with `{ "inbox_id": 41, "duplicate": false }`. Retrying a post with the same `id` is safe.
## Follow the live feed
[Section titled “Follow the live feed”](#follow-the-live-feed)
```sh
curl -N localhost:7878/v1/agents/assistant/sessions/ana/live -H "Authorization: Bearer $TOKEN"
```
Each frame has an `id` (its sequence number) and a JSON payload with a `type`: `received`, `turn_started`, `turn_steered`, `tool_started`, `tool_progress`, `tool_finished`, `turn_completed` or `turn_failed`. Payloads hold names, counts and timings, not message text. Resume with `Last-Event-ID` or `?cursor=`.
Replies reach the person through your own outward tool, such as `send_message`, not through the feed.
## Delete a session
[Section titled “Delete a session”](#delete-a-session)
`DELETE` removes the session’s events, history, checkpoints, journal and live feed. Use it when a customer leaves or asks for their data to be erased: newt never deletes history on its own. It answers `409` while a turn holds the lease; retry after the turn ends.
## Errors
[Section titled “Errors”](#errors)
Errors are `{ "error": "" }` with status 400 (bad body or key), 401 (missing or wrong token, or unknown agent), 404 (no such session), 409 (conflict) or 500.
## Next
[Section titled “Next”](#next)
* [Protocol, section 5](../protocol/#5-event-api-called-by-your-application): every field and status.
* [Replay and fork](../replay/).
# Load testing
> Run load against newt with a fake model and a mock app, so you measure newt and pay for no tokens.
`newt-loadtest` runs load against newt with no real model behind it. It has three parts.
| Part | What it does |
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `fake-model` | Speaks the OpenAI Responses stream with random text. Timings and tool use come from a production assistant’s profile: about 16 tool calls per turn. Prompt caching is simulated per prefix, so a rewritten history misses as it would against a real model. |
| `mock-app` | A context provider with a production-sized prompt and workspace, and an MCP server whose tools have that assistant’s latencies and error rates. |
| `run` | Drives sessions against the events API and live feed, and reports read receipt, time to turn start, turn duration, model calls, cache rate and tool calls. |
## Run it
[Section titled “Run it”](#run-it)
```sh
newt-loadtest fake-model --speed 1 & # --speed 10 compresses every delay tenfold
newt-loadtest mock-app --speed 1 &
LOADTEST_TOKEN=... newt-loadtest run --base http://127.0.0.1:7878 --agent assistant \
--sessions 200 --turns 5 --concurrency 200 --think-ms 5000 \
--fake http://127.0.0.1:9100 --mock http://127.0.0.1:9200
```
Point a route’s `base_url` at `http://127.0.0.1:9100/v1` and the agent’s `context_provider` at `http://127.0.0.1:9200/agent`.
## The fake model’s cache
[Section titled “The fake model’s cache”](#the-fake-models-cache)
A request reuses the longest run of 128-token blocks that an earlier request with the same `prompt_cache_key` wrote: at least 1,024 tokens, readable 2 seconds after the write, with a five-minute expiry.
## One command
[Section titled “One command”](#one-command)
`scripts/bench.sh` does all of this in one command and adds newt’s CPU, memory and cache report card. See [Benchmark](../benchmark/) for the method and results.
# MCP-only agents
> Build an agent from one MCP server and nothing else. Its instructions, tools, skills and resources become the agent.
An agent can be nothing but an MCP server. Build the server and the agent comes with it: no context provider, and nothing in the server needs to know about newt.
```toml
[agents.tasks]
mcp = "https://app.example.com/tasks/mcp"
model = "default"
token_env = "TASKS_TOKEN"
```
newt sends the agent’s token to the server as the bearer.
## What newt takes from the server
[Section titled “What newt takes from the server”](#what-newt-takes-from-the-server)
| The agent’s | Comes from |
| --------------- | -------------------------------------------------------------- |
| Instructions | The server’s `instructions`. Empty instructions fail the turn. |
| Model | `model` in `server.toml` |
| Tools | `tools/list` |
| Skills | `skills/list`, named `{agent}/{skill path}` (experimental) |
| Workspace pages | The session’s resources, loaded at the start of each turn |
There is no `version`, `pinned`, turn `instructions` or `constraints`. newt reconnects when the tool set goes stale, which includes the server restarting.
## Resources are the workspace
[Section titled “Resources are the workspace”](#resources-are-the-workspace)
At the start of each turn newt lists and reads the resources with `_meta: { "newt/session": "" }`, so one server can serve every session. Each text resource becomes a page:
| Page field | From the resource |
| ---------- | ----------------------------------------------- |
| `path` | `name` |
| `key` | `_meta["newt/key"]` when set, otherwise `[uri]` |
| `version` | The `sha256:` digest of the text |
| `content` | The text contents |
## Keeping pages fresh
[Section titled “Keeping pages fresh”](#keeping-pages-fresh)
newt subscribes to the session’s resources for the rest of the turn. `notifications/resources/updated` marks one page stale and `notifications/resources/list_changed` marks them all; the next read fetches again.
Tip
The update notification travels on its own stream, so it can arrive after the tool result that caused it. A tool that changes a page should also return `newt/invalidate`, or the page itself in `newt/pages`, so the model’s next read sees the change for certain.
## Next
[Section titled “Next”](#next)
* `assistant-mcp` in `crates/newt-demo` is a complete example. Run it with the [Quickstart](../quickstart/).
* [Protocol 2.7](../protocol/#27-an-agent-that-is-only-an-mcp-server) has every rule.
# Memory and compaction
> How the agent saves facts, and how newt compacts long history into a checkpoint without dropping a commitment.
A session can run for months. Two things keep its history useful: the agent saves what matters, and newt compacts the rest when it grows too long.
## Saving facts
[Section titled “Saving facts”](#saving-facts)
The built-in `remember` tool saves one durable fact per call (“Lives in Lisbon”). It is kept in history as an append, like any `durable` tool.
## Compaction
[Section titled “Compaction”](#compaction)
When live history passes `history_budget` tokens (60,000 by default, set in the bootstrap), newt compacts right after a turn commits:
1. The agent’s own model gets the same prompt plus one instruction.
2. It saves anything not yet recorded.
3. It calls `checkpoint` with its memory, open threads, commitments, decisions and tone.
Nothing is sent while compacting: no outward call runs.
## Commitments cannot vanish
[Section titled “Commitments cannot vanish”](#commitments-cannot-vanish)
newt refuses a checkpoint that drops a commitment from the previous one, unless the checkpoint lists it as resolved. It also refuses an empty checkpoint, or one longer than a quarter of the budget. The agent gets the refusal as a tool result and tries again.
## What later turns see
[Section titled “What later turns see”](#what-later-turns-see)
```text
checkpoint → the most recent whole turns that fit in a quarter of the budget → anything saved during the flush
```
Raw history is never deleted. The checkpoint is a cut point, not a replacement. A compaction that fails waits before the next try (ten minutes, doubling, at most a day) and the session keeps running on its long history meanwhile.
Note
Compaction is the one planned cache miss in a long session: it replaces the older part of history once per budget crossing.
## Next
[Section titled “Next”](#next)
* [Design: compaction, checkpoints and commitments](../design/#compaction-checkpoints-and-commitments).
# Protocol
> This document specifies the two interfaces an application implements for newt, and the small event API it uses to talk to newt.
This document specifies the two interfaces an application implements for newt, and the small event API it uses to talk to newt:
1. the **context provider**, three JSON endpoints newt calls over HTTP, which an agent can skip by being only an MCP server (2.7);
2. **MCP tools**, served over Streamable HTTP, with newt’s `_meta` fields;
3. the **event API**, which the application calls on newt.
It is written from `crates/newt-server/src/{context,http}.rs`, `crates/newt-server/src/mcp/`, `crates/newt-core/src/{workspace,tools,skills,checkpoint,batch,commit}.rs` and the reference implementation in `crates/newt-demo`. Anything not confirmed in code is marked “(unverified)”.
Terms: an **agent** is a name in `[agents.]` in `server.toml`. A **session** is one conversation of one agent, identified by a **session key**, a string of 1 to 200 bytes with no `/`. A **turn** is one run of the model loop over the events that arrived for a session.
## 1. Context provider
[Section titled “1. Context provider”](#1-context-provider)
Each agent names a base URL (`context_provider`) and a token variable (`token_env`). Below, `{base}` is the base URL with any trailing `/` removed.
### 1.1 Conventions
[Section titled “1.1 Conventions”](#11-conventions)
* Every request carries `Authorization: Bearer `, where the token is the value of the agent’s `token_env`. This is the same token callers of the event API use.
* Bodies are JSON. Newt sends `POST` bodies with `content-type: application/json` and does not send a body on `GET`.
* Each request has a 10 second timeout. Newt does not retry inside a request.
* A non-2xx response fails the call. The error keeps the status and the first 300 characters of the body.
* Decoding is strict. The bootstrap, the turn response, the pages response, each skill and each MCP server entry reject unknown fields. A page object tolerates extra fields.
* The protocol has no version field of its own. `version` below is the version of your bootstrap.
* A failure in any call, or a rejected bootstrap, stops the turn from starting. The session’s events stay pending, the lease is released with exponential backoff (2 s doubling, capped at 300 s) and the sweeper tries again. The error is logged as `context provider for ``: ...`.
### 1.2 Call order
[Section titled “1.2 Call order”](#12-call-order)
At the start of every turn newt calls `POST {base}/turn`. The response names a `version`. If newt holds no bootstrap for that agent at that version, or its tool set went stale since (section 2.1), it then calls `GET {base}/bootstrap`, which must return the same version, and connects the MCP servers. Otherwise it reuses the loaded bootstrap and tool set. Newt keeps one loaded bootstrap per agent in memory.
`POST {base}/pages` is called during the turn, when the agent reads a page that a tool made stale, and once at the start when `turn` returned no `pages`.
### 1.3 `GET {base}/bootstrap`
[Section titled “1.3 GET {base}/bootstrap”](#13-get-basebootstrap)
Everything that is the same for every session at one version. Changing any field means a new `version`; the first turn after that pays one prompt cache miss, because the instructions, skills index and tool list form the cached prefix.
Response:
| Field | Type | Required | Default | Meaning |
| ------------------- | ------------------------------- | -------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `version` | string | yes | | Non-empty after trimming. Must equal the `version` in the turn response. |
| `instructions` | string | yes | | The system prompt. Non-empty after trimming. |
| `model` | string | yes | | A `[models]` alias from `server.toml`. Unknown alias is an error. |
| `first_call_effort` | `"low"`, `"medium"` or `"high"` | no | none | Reasoning effort for the first model call of a turn. When absent, newt sends no effort. |
| `effort` | `"low"`, `"medium"` or `"high"` | no | none | Effort for later calls in the turn. |
| `max_output_tokens` | integer | no | 4096 | Per model call. Must be at least 1. |
| `max_model_calls` | integer | no | 8 | Model calls per turn. The turn fails if it uses them all without ending. Must be at least 1. |
| `max_turn_tokens` | integer | no | none | Input plus output tokens one turn may spend across its model calls. Past it, the model is told to send what it has and call `end_turn`; if its next call does not end the turn, the turn fails. |
| `recite` | boolean | no | `false` | Recitation: on long turns, from the fifth model call and every fourth after, append a `` note quoting the turn’s events. Lives in scratch only. Experimental. |
| `history_budget` | integer | no | 60000 | Tokens of live history before compaction. Must be at least 1000. |
| `skills` | array of skill | no | `[]` | See 1.7. |
| `mcp_servers` | array of server | no | `[]` | See 2.1. |
```json
{
"version": "2026-11-12.1",
"instructions": "You are a personal assistant for one person, reached by chat. ...",
"model": "default",
"first_call_effort": "low",
"effort": "medium",
"max_output_tokens": 4096,
"max_model_calls": 8,
"history_budget": 60000,
"skills": [
{
"name": "writing",
"description": "How to word a message to the person.",
"content": "Short sentences. Lead with the answer."
}
],
"mcp_servers": [
{ "name": "app", "url": "https://app.example.com/mcp", "auth": "agent_token" }
]
}
```
### 1.4 `POST {base}/turn`
[Section titled “1.4 POST {base}/turn”](#14-post-baseturn)
Called at the start of every turn, before the first model call.
Request:
```json
{ "session": "ana" }
```
`session` is the session key from the event URL, without the agent name.
Response:
| Field | Type | Required | Default | Meaning |
| -------------- | --------------- | -------- | ------- | -------------------------------------------------------------------------------------------------------- |
| `version` | string | yes | | The bootstrap version this response was built for. |
| `pages` | array of page | no | absent | The session’s workspace. Absent or `null` means load lazily (1.5). `[]` means a workspace with no pages. |
| `pinned` | string | no | none | Session-stable text. Trimmed; empty is ignored. |
| `instructions` | string | no | none | Guidance for this turn only. |
| `constraints` | array of string | no | `[]` | Rules that hold right now, for this turn only. Entries are trimmed; empty ones are dropped. |
```json
{
"version": "2026-11-12.1",
"pages": [
{ "key": ["tasks"], "path": "tasks.md", "version": "7", "content": "# Tasks\n- [ ] #1 Call the bank\n" },
{ "key": ["notes"], "path": "notes.md", "version": "7", "content": "# Notes\n- Lives in Lisbon\n" }
],
"pinned": "Write for WhatsApp: no markdown, two short paragraphs at most.",
"instructions": "It is 2026-11-12T09:30:00Z.",
"constraints": ["Do not message the person before Monday: they are on holiday."]
}
```
Where each field goes:
* `pinned` is appended to the system prompt as `\n\n\n{pinned}\n`, before the skills index. It is part of the cached prefix: free while it stays the same, one cache miss when it changes.
* `instructions` and `constraints` are rendered into one message at the start of the turn’s scratch, and never enter history:
```plaintext
It is 2026-11-12T09:30:00Z.
- Do not message the person before Monday: they are on holiday.
```
If both are empty, no message is added.
* `constraints` are text the model reads. They do not block a tool call. Enforce any constraint that must hold in your tool handlers as well.
### 1.5 Pages and the workspace
[Section titled “1.5 Pages and the workspace”](#15-pages-and-the-workspace)
A **page** is one file in the agent’s workspace. The agent reads them with the built-in `read_file`, `glob` and `grep` tools.
Page object:
| Field | Type | Meaning |
| --------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `key` | array of string, non-empty | Invalidation key. Tools invalidate pages by key prefix (2.3). Several pages may share a key. |
| `path` | string, non-empty | What the agent sees. Unique within one response. Glob patterns match it with `/` as a literal separator (`*` does not cross `/`, `**` does). |
| `version` | string | Opaque. Newt reports it back to tools in `newt/pages_read`. |
| `content` | string | The page text. |
```json
{ "key": ["customer", "profile"], "path": "profile.md", "version": "12", "content": "# Profile\nRemote only.\n" }
```
A response with an empty `key`, an empty `path` or a repeated `path` is rejected as a whole.
**Eager loading.** `turn` returns `pages`. The workspace starts with them.
**Lazy loading.** `turn` leaves out `pages`. Newt starts the first model call at once and requests every page in the background with `POST {base}/pages` and `keys: [[]]`. The first `read_file`, `glob` or `grep` waits for that load. If it fails, those tools return the error for the rest of the turn. Use this when rendering pages is slow.
**Refetching.** A successful tool call can make pages stale (2.3). The next read of a stale page asks the provider for it again, and `grep` refreshes all stale pages first. After a tool call, stale pages the agent has already read in this turn are also refreshed in the background.
### 1.6 `POST {base}/pages`
[Section titled “1.6 POST {base}/pages”](#16-post-basepages)
Request:
```json
{ "session": "ana", "keys": [["tasks"], ["customer", "profile"]] }
```
`keys` is a list of page keys. For a refetch newt sends the exact key of each stale page. The empty key `[]` means every page. Return the pages whose key starts with any listed key; `[]` matches all of them.
Response:
```json
{ "pages": [ { "key": ["tasks"], "path": "tasks.md", "version": "8", "content": "# Tasks\n..." } ] }
```
Rules newt applies to the response:
* A page it asked for but that is missing from the response is deleted from the workspace.
* A returned page replaces the current one only if the page was one of those requested and has not been invalidated again while the request was in flight. If it was, the response for it is discarded and it stays stale.
* A returned page whose `path` the workspace does not have yet is added.
* The response is checked like any other page list (see the page object above).
### 1.7 Skills
[Section titled “1.7 Skills”](#17-skills)
A skill is instructions for one kind of task, loaded when the agent needs them.
```json
{ "name": "writing", "description": "How to word a message to the person.", "content": "Short sentences." }
```
All three fields are required strings. Newt appends this block to the system prompt, after `pinned`:
```plaintext
Load a skill with load_skill before doing what it covers.
- writing: How to word a message to the person.
```
and adds a built-in `load_skill` tool whose `name` argument is one of the skill names. The skill’s `content` comes back as the tool result. It stays in that turn’s scratch and never enters history. Newt does not check that bootstrap skill names are unique. Loaded skill names are passed to MCP tools in `newt/skills_loaded`.
MCP servers can serve skills too (section 2.6). Those follow the bootstrap’s skills in the index, sorted by name.
## 2. MCP tools
[Section titled “2. MCP tools”](#2-mcp-tools)
Tools come from the servers in the bootstrap’s `mcp_servers`. A server that knows nothing about newt still works; its tools are `durable` and invalidate nothing.
### 2.1 Servers and connections
[Section titled “2.1 Servers and connections”](#21-servers-and-connections)
```json
{ "name": "app", "url": "https://app.example.com/mcp", "headers": { "x-tenant": "acme" }, "auth": "agent_token" }
```
| Field | Type | Required | Default | Meaning |
| --------- | --------------------------- | -------- | -------- | ---------------------------------------------------------------------------------------------- |
| `name` | string | yes | | Used in logs and errors. |
| `url` | string | yes | | Must start with `http://` or `https://`. Streamable HTTP endpoint. |
| `headers` | object of string to string | no | `{}` | Static headers sent on every request. Do not put secrets here; use `auth` for the agent token. |
| `auth` | `"none"` or `"agent_token"` | no | `"none"` | `"agent_token"` sends `Authorization: Bearer `. |
Newt connects to each server in order and lists all its tools. All servers together get 20 seconds. Newt speaks MCP `2026-07-28` (`server/discover`, no handshake) and falls back to the `2025-11-25` `initialize` handshake for a server that does not answer `server/discover`. Two servers offering the same tool name is an error. Connections are shared by every session of the same bootstrap version.
Newt sorts each server’s tools by name, so the tool list in the cached prompt prefix is byte-identical whatever order a server lists them in. Servers keep the order the bootstrap lists them in.
If a server accepts `subscriptions/listen` for `toolsListChanged`, newt keeps that subscription open. A `notifications/tools/list_changed` marks the tool set stale, and so does the subscription ending. A stale tool set is also what a closed or unsendable transport causes during a call. Either way, the next turn fetches the bootstrap again, reconnects and lists the tools again. A server that does not offer the subscription is only relisted on a new bootstrap version or a broken connection. A proxy that cuts long-lived streams causes one relist per cut.
Every call may go 90 seconds without a result or a `notifications/progress` for its progress token (newt always sends one), and at most 10 minutes in total, however much progress it reports. A call past either limit is cancelled with `notifications/cancelled`, and so is a call whose turn ends before it answers. Each progress notification also reaches the session’s live feed as a `tool_progress` event (5.3) carrying the notification’s `message`, or `{progress} of {total}` without one, so the app can show “searching…” while a tool runs.
### 2.2 Tool `_meta`
[Section titled “2.2 Tool \_meta”](#22-tool-_meta)
Set on each tool in the `tools/list` response. All optional.
| Field | Type | Default | Meaning |
| ------------------ | ----------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------- |
| `newt/retention` | `"ephemeral"`, `"durable"` or `"outward"` | `"durable"` | What reaches history (section 3). |
| `newt/invalidates` | array of page key | `[]` | Page key prefixes that every successful call makes stale. |
| `newt/idempotent` | boolean | `false` | Must be `true` on an `outward` tool. Declares that the handler honours `newt/idempotency_key`. |
```json
{
"name": "send_message",
"description": "Send a chat message to the person.",
"inputSchema": { "type": "object", "properties": { "text": { "type": "string" } }, "required": ["text"] },
"_meta": { "newt/retention": "outward", "newt/idempotent": true }
}
```
```json
{
"name": "complete_task",
"description": "Mark a task done by its number.",
"inputSchema": { "type": "object", "properties": { "id": { "type": "integer" } }, "required": ["id"] },
"_meta": { "newt/retention": "durable", "newt/invalidates": [["tasks"]] }
}
```
A field with the wrong type or value, or an `outward` tool without `newt/idempotent: true`, makes the whole tool set fail to load, and the turn does not start. Tool names must not collide with newt’s built-ins: `read_file`, `glob`, `grep`, `end_turn`, `remember`, `batch`, `checkpoint`, `load_skill`. A collision fails the turn with `two tools share the name`.
Newt does not validate call arguments against `inputSchema`. It checks only that `arguments` is a JSON object. Validate in the handler.
### 2.3 Call `_meta`
[Section titled “2.3 Call \_meta”](#23-call-_meta)
Newt sends these in `params._meta` of every `tools/call`:
| Field | Type | Meaning |
| ---------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `newt/session` | string | The session key, without the agent name. |
| `newt/idempotency_key` | string | `{agent}/{session}:{turn}:{call id}`. Opaque to you. Stable across retries and replays of the same call (section 4). For a call made through the `batch` tool the call id is `{batch call id}.{index}`. |
| `newt/skills_loaded` | array of string | Skills the agent has loaded this turn, sorted. Lets a handler refuse an action until the right skill was read. |
| `newt/pages_read` | array of `{ path, version }` | Pages the agent has read this turn, sorted by path, with the version newt holds. Lets a handler refuse to act on a page that has since changed. |
| `traceparent` | string | W3C trace context of the call’s `execute_tool` span, as newt’s OTLP exporter emits it. Parent your own spans on it to see them under the turn’s trace. Sent whether or not OTLP export is on. |
| `progressToken` | string or number | Standard MCP. Report `notifications/progress` with it to keep a long call alive (section 2.1). |
```json
{
"name": "send_message",
"arguments": { "text": "Reminder set for tomorrow." },
"_meta": {
"newt/session": "ana",
"newt/idempotency_key": "assistant/ana:0198f2a4-6b1e-7c3d-9a10-5e7d2c8b4f01:call_9xk2",
"newt/skills_loaded": ["writing"],
"newt/pages_read": [
{ "path": "notes.md", "version": "7" },
{ "path": "tasks.md", "version": "7" }
]
}
}
```
### 2.4 Result `_meta`
[Section titled “2.4 Result \_meta”](#24-result-_meta)
Set on the `tools/call` result. All optional. Newt applies them only to results that are not errors.
| Field | Type | Meaning |
| ----------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `newt/summary` | string | What history keeps instead of the full result (section 3). |
| `newt/invalidate` | array of page key | More key prefixes to mark stale, in addition to the tool’s `newt/invalidates`. |
| `newt/pages` | array of page | Fresh page data. Newt puts these pages in the workspace and clears their stale flag, so the next read needs no refetch. |
```json
{
"content": [{ "type": "text", "text": "{\"task\":2}" }],
"_meta": {
"newt/summary": "Added task #2: Call the bank",
"newt/pages": [
{ "key": ["tasks"], "path": "tasks.md", "version": "8", "content": "# Tasks\n- [ ] #1 Call the bank\n- [ ] #2 Call the bank\n" }
]
}
}
```
Invalidation reaches only pages newt already has. A tool that creates a page should return it in `newt/pages`. When a call changes pages, newt appends `(workspace pages changed: a.md, b.md)` to the result the model sees.
If any of the three fields is malformed (wrong type, or a page list that breaks the page rules), newt drops all three, marks every page stale (`[[]]`), appends a note to the result, and logs an error. The call itself still counts as having run.
### 2.5 Result content and errors
[Section titled “2.5 Result content and errors”](#25-result-content-and-errors)
* The model sees the text blocks joined with newlines. An image becomes `[image]`, audio `[audio]`, any other block `[resource]`. If there are no content blocks, the JSON of `structuredContent` is used.
* `isError: true` makes the result an error result.
* A JSON-RPC error from the server becomes `The tool call failed: `.
* A broken connection, 90 seconds without a result or progress, or 10 minutes in total, becomes an error result that says the outcome is unknown and tells the model not to repeat the call but to check the workspace first.
* An `input_required` result (the MCP multi round-trip pattern) whose requests are all elicitations (a form, or a link) is how a tool asks the person before it acts, for example to approve a payment. Newt declares the `elicitation` capability (form and url) and turns the result into an error result that says nothing was done yet, lists each question with its fields, and tells the model to ask the person, then call the tool again with the same arguments plus `"_input": { "answers": { "": { ...fields } or "decline" }, "state": "" }`. Newt takes `_input` out of the arguments and sends the answers as `inputResponses` (`{ "action": "accept", "content": ... }`, or `{ "action": "decline" }`) and `state` as `requestState`. The question is also the result’s `newt/summary`, so a `durable` or `outward` tool keeps it in history and the answer can come in a later turn; nothing is stored in between. Requests for sampling or roots fail the call with nothing done. A tool with `additionalProperties: false` in its `inputSchema` should still accept `_input`, which never reaches the handler’s arguments.
* Newt declares the `io.modelcontextprotocol/tasks` extension. A call the server answers with a task is polled with `tasks/get` at the task’s `pollInterval` (default 1 s, kept between 250 ms and 10 s) for up to an hour, instead of the 10 minute call limit, and its completed result is read like any other. A task that fails, is cancelled, asks for input or outlives the hour becomes an error result that says the outcome is unknown. Polling is not journaled: if newt restarts while it waits, the call’s outcome is unknown, as for any call.
### 2.6 Skills served over MCP
[Section titled “2.6 Skills served over MCP”](#26-skills-served-over-mcp)
A server that declares the `io.modelcontextprotocol/skills` extension ([SEP-2640](https://modelcontextprotocol.io/seps/2640-skills-extension)) can serve skills in the [Agent Skills](https://agentskills.io/specification) format instead of putting them in the bootstrap. When newt connects, it calls `skills/list` (every page) and offers each skill under the name `{server}/{skill path}`: the server’s name from the bootstrap, then the skill’s URI without its scheme and `/SKILL.md`. `skill://acme/billing/refunds/SKILL.md` on server `app` becomes `app/acme/billing/refunds`. The description is the frontmatter’s `description`.
A skill is not offered, with a warning in the log, if its `resources` is `"dynamic"` (newt cannot verify it), its URI does not end in `/{name}/SKILL.md`, it has more than 512 files or more than 16 MiB, its file list leaves out its own `SKILL.md`, or it lists a file outside its directory. Two skills from one server that would get the same name, or an MCP skill with the same name as a bootstrap skill, fail the bootstrap load.
Nothing is fetched until the model loads a skill. Then newt reads `SKILL.md` with `resources/read`, checks its size and `sha256` digest against the listing, and checks that its frontmatter equals the listing’s `frontmatter`. The result is the file, followed by the skill’s other files when it has any:
```plaintext
Read one with load_skill and `file`:
- references/tone.md
```
When any skill is served over MCP, `load_skill` also takes `file`, a path relative to the skill. Only files the listing names can be read, each checked the same way. Absolute paths, `..`, `.` and empty segments are refused. Verified files are cached by server, URI and digest for as long as the tool set lives. A file that fails a check is never shown to the model: the call fails, and the tool set is marked stale so the next turn lists the skills again. Only text contents are read.
### 2.7 An agent that is only an MCP server
[Section titled “2.7 An agent that is only an MCP server”](#27-an-agent-that-is-only-an-mcp-server)
An agent can skip the context provider entirely: `[agents.] mcp = ""` with a `model` ([Configuration](../configuration/)). Newt then gets everything from that one server, over plain MCP:
| Context provider field | From the MCP server |
| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| bootstrap `instructions` | The server’s `instructions` (`server/discover`, or `initialize` on `2025-11-25`). Empty instructions fail the turn. |
| bootstrap `model` | `model` in `server.toml`. The other bootstrap knobs take their defaults (1.3). |
| bootstrap `skills` | `skills/list` (2.6). Skills are named `{agent}/{skill path}`. |
| bootstrap `mcp_servers` | The server itself, with `auth: "agent_token"`. |
| `version` | None. Newt reconnects when the tool set goes stale (2.1), which includes the server restarting. |
| turn `pages` | The server’s resources for the session, loaded lazily (1.5). |
| turn `pinned`, `instructions`, `constraints` | None. |
**Pages as resources.** At the start of each turn newt calls `resources/list` (every page) and reads each resource with `resources/read`, both with `_meta: { "newt/session": "" }`, so one server can serve every session. Each text resource becomes a page:
| Page field | From the resource |
| ---------- | -------------------------------------------------------------------- |
| `path` | `name` |
| `key` | `_meta["newt/key"]` when set (an array of string), otherwise `[uri]` |
| `version` | The `sha256:` digest of the text |
| `content` | The text contents whose `uri` matches |
Resources with a `skill://` URI are skills, not pages, and are left out. A resource with no text contents is left out with a warning. The page rules of 1.5 apply: two resources with the same `name` fail the load. Refetching a stale page lists the resources again and reads the matching ones, so a page that is no longer listed disappears and a new one appears.
**Invalidation.** Once the pages are listed, newt opens `subscriptions/listen` with `resourceSubscriptions` set to the session’s resource URIs, and `resourcesListChanged`, for the rest of the turn. `notifications/resources/updated` marks that resource’s page stale, and `notifications/resources/list_changed` marks every page stale; the next read refetches. The subscription follows the URIs of the latest listing and closes when the turn ends. A server that does not accept resource subscriptions still works: its pages are fresh at the start of each turn, and tool `_meta` (2.2, 2.4) can still invalidate them during one.
The update notification travels on its own stream, so it can arrive after the tool result that caused it. A tool that changes a page and wants the model’s next read to see the change for certain should also return `newt/invalidate` or the page itself in `newt/pages` (2.4).
A minimal agent is an MCP server with instructions, tools that do things, and resources that show the person’s state. Nothing in it needs to know about newt.
## 3. Retention classes
[Section titled “3. Retention classes”](#3-retention-classes)
Retention decides what a turn leaves in history. Everything the model saw in the turn’s scratch is dropped at commit except the parts below. The prompt has three layers: a frozen prefix (system prompt and tool list), history, and the turn’s scratch.
| Class | Call and result in history | Intended for |
| ----------- | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| `ephemeral` | Both dropped when the turn commits. | Reads and lookups. Everything the agent needs from them it can read again. |
| `durable` | Call kept. The result is replaced by `newt/summary`; with no summary it is cut to 500 characters. | Writes to your system the agent should remember doing. |
| `outward` | Kept like `durable`. Also journaled before it runs and run under the rules in section 4. | Anything with an effect outside your system: a message, an email, a payment. |
Also dropped at commit: the model’s reasoning, the turn-context message, and harness reminders. Assistant text is kept. Built-in tools: `read_file`, `glob`, `grep`, `end_turn`, `batch`, `checkpoint` and `load_skill` are `ephemeral`; `remember` is `durable` with the summary `Remembered.`.
## 4. Exactly-once outward calls
[Section titled “4. Exactly-once outward calls”](#4-exactly-once-outward-calls)
Newt guarantees that an outward call is attempted again after a crash with the same idempotency key. It cannot make your handler idempotent: the pair (newt’s journal, your handler’s dedupe on the key) is what gives an effect exactly once.
What newt does for an `outward` call:
1. It writes an `outward_intent` entry to the turn journal in Postgres and waits for the write to be acknowledged. If that write fails, or an earlier journal write was lost, the call is not made and the model gets `Not sent: ...`.
2. It calls the tool with `newt/idempotency_key`. The turn id and call ids are fixed when the model’s response is journaled, so the key is the same on every replay.
3. It records the tool output in the journal. This write is not awaited, so a crash can lose it.
4. On recovery, newt replays the journal. A call with a recorded output gets that output back and is not made again. A call with no recorded output, including one with an intent entry and nothing after it, is made again with the same key.
What the handler must do:
* Store the key in the same transaction as the effect.
* On a repeated key, return the original result (with the same `newt/summary`) and do nothing else.
Ordering rules in a model step:
* Outward calls run after all other calls of the step, one at a time.
* At most one outward call per `batch` call; `batch` holds up to 10 calls and cannot contain `batch`, `end_turn` or `checkpoint`.
* If an outward call fails, the later outward calls of the step are skipped (`Not run: ...`).
* During compaction no outward call runs.
* Every tool call, not only outward ones, carries an idempotency key. Only outward calls are journaled before running.
The reference handler is `send_message` in `crates/newt-demo/src/tools.rs`: it records the key and sends only the first time.
## 5. Event API (called by your application)
[Section titled “5. Event API (called by your application)”](#5-event-api-called-by-your-application)
Authentication is the same bearer token. Agent and session key are in the path.
| Method and path | Purpose |
| ----------------------------------------------- | -------------------------------------------------- |
| `POST /v1/agents/{agent}/sessions/{key}/events` | Add an event to the session. |
| `GET /v1/agents/{agent}/sessions/{key}` | Session status. |
| `GET /v1/agents/{agent}/sessions/{key}/live` | Server-sent events for the session. |
| `GET /v1/agents/{agent}/sessions/{key}/history` | The session’s history. |
| `POST /v1/agents/{agent}/sessions/{key}/fork` | A new session that starts with this one’s history. |
| `DELETE /v1/agents/{agent}/sessions/{key}` | Delete the session and everything stored for it. |
| `GET /healthz` | 200 when Postgres answers, 503 otherwise. No auth. |
Errors are `{ "error": "" }` with status 400 (bad body or key), 401 (missing or wrong token, or unknown agent), 404 (no such session), 409 (conflict, see each endpoint) or 500.
### 5.1 `POST .../events`
[Section titled “5.1 POST .../events”](#51-post-events)
```json
{ "id": "m1", "text": "Remind me to call the bank tomorrow.", "kind": "message", "policy": "steer", "priority": "interactive", "meta": { "channel": "chat" } }
```
| Field | Type | Required | Default | Meaning |
| ---------- | ----------------------------------- | -------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `id` | string | yes | | Your id for the event, 1 to 200 bytes. A repeat of the same `id` in the same session is accepted and not queued again, for as long as newt keeps the consumed event (`[retention] inbox_days`, 30 days by default). |
| `text` | string | yes | | What the agent reads. Must not be empty after trimming. |
| `kind` | `"message"` or `"signal"` | no | `"message"` | Stored. Nothing else reads it. |
| `policy` | `"steer"`, `"queue"` or `"collect"` | no | `"steer"` | `steer`: if a turn is running, the event joins it between model calls, and interrupts a model call that has not started producing output. `queue` and `collect`: the event waits for the next turn. Only `steer` is treated differently in code. |
| `priority` | `"interactive"` or `"background"` | no | `"interactive"` | A turn that includes any interactive event runs in the interactive lane. Background model calls may use at most `max_concurrent - 1` of a route’s slots (at least 1), which keeps a slot free for interactive turns. |
| `meta` | any JSON | no | none | Stored with the event. Not shown to the model. |
Unknown fields are rejected. Response, status 202:
```json
{ "inbox_id": 41, "duplicate": false }
```
### 5.2 `GET .../sessions/{key}`
[Section titled “5.2 GET .../sessions/{key}”](#52-get-sessionskey)
```json
{ "lease_owner": "host-1234-0198f2a4", "lease_epoch": 3, "pending_events": 0, "history_entries": 12, "live_seq": 9 }
```
`lease_owner` is `null` when no runner holds the session. 404 if the session has never received an event.
### 5.3 `GET .../live`
[Section titled “5.3 GET .../live”](#53-get-live)
A `text/event-stream`. Each frame has `id:` (the event’s sequence number in the session) and a JSON `data:` payload. Resume with the `Last-Event-ID` header or `?cursor=`. Frames older than `[retention] live_days` (7 days by default) are deleted, so a resume from further back starts at the oldest frame kept. The server also sends keep-alives. The `type` field is one of `received` (`event_id`, `inbox_id`; sent when an event is accepted), `turn_started`, `turn_steered`, `tool_started`, `tool_progress` (`call_id`, `name`, `message`), `tool_finished`, `turn_completed` and `turn_failed`. All but `received` also carry `at` (RFC 3339) and `session` and `turn`. Payloads hold names, counts and timings, and error text on failure; they do not hold message text.
```plaintext
id: 1
data: {"type":"received","event_id":"m1","inbox_id":41}
id: 2
data: {"at":"2026-11-12T17:30:00.120Z","type":"turn_started","session":"assistant/ana","turn":"0198f2a4-...","history_messages":0,"events":1,"compaction":false}
```
### 5.4 `GET .../history`
[Section titled “5.4 GET .../history”](#54-get-history)
The session’s history entries in order. 404 if there is no such session.
```json
{ "entries": [ { "seq": 0, "turn": "0198f2a4-...", "kind": "message", "entry": { "role": "user", "parts": [ { "type": "text", "text": "Add milk." } ] } } ] }
```
`entry` is a message as newt stores it: `role` is `user` or `assistant`, and each part has a `type` of `text`, `tool_call` (`id`, `name`, `arguments`) or `tool_result` (`call_id`, `content`, `is_error`). Only what the retention rules kept is there (section 3).
### 5.5 `POST .../fork`
[Section titled “5.5 POST .../fork”](#55-post-fork)
```json
{ "into": "ana-try", "agent": "assistant-canary", "through_turn": "0198f2a4-..." }
```
| Field | Type | Required | Default | Meaning |
| -------------- | ------ | -------- | -------------- | ------------------------------------------------------------------- |
| `into` | string | yes | | The new session’s key. It must not exist yet. |
| `agent` | string | no | the same agent | The new session’s agent. The bearer token must be valid for it too. |
| `through_turn` | string | no | all of it | Copy history through this turn (a `turn` from 5.4). |
Newt creates the session with a copy of the history and checkpoints up to the cut, and nothing else: no pending events, journal or live feed. The next event sent to it starts a turn on that history, as the target agent, with its prompt, tools and model. Response, status 201:
```json
{ "agent": "assistant-canary", "key": "ana-try", "history_entries": 12 }
```
404 if the source session does not exist, 400 for an unknown `through_turn`, 409 if `into` already exists.
`newt-replay` builds on these two. `newt-replay assistant/ana assistant-canary/ana-replay` sends each turn’s events from `assistant/ana` to the new session again, one turn at a time, waits for the turn to finish, and prints what the turn did then and what it did now. `--from-turn N` forks the first N turns in instead of replaying them, and `--turns N` stops after N. Point it at a server with `--url`, and name the variable holding the token with `--token-env` (default `NEWT_TOKEN`). A replay runs the target agent’s tools for real, so give the target agent an MCP server that is safe to call: a staging app, or `newt-loadtest mock-app`. Give it the `newt-loadtest fake-model` route to replay for free, or a real model to compare prompts or models.
### 5.6 `DELETE .../sessions/{key}`
[Section titled “5.6 DELETE .../sessions/{key}”](#56-delete-sessionskey)
Deletes the session with its pending events, history, checkpoints, journal and live feed. Use it when a customer leaves or asks for their data to be erased; newt never deletes history on its own. Status 204 on success, 404 if there is no such session, 409 while a runner holds the session’s lease (a turn is running or about to). Retry a 409 after the turn ends. An event sent after the delete starts a new, empty session under the same key.
# Replay and fork
> Copy a real conversation into a new session, then replay it against a new prompt or model and compare each turn.
Experimental
Replay and fork work and are tested, but their shape may change within 1.x.
## Fork a session
[Section titled “Fork a session”](#fork-a-session)
`POST /v1/agents/{agent}/sessions/{key}/fork` starts a new session with a copy of another’s history, up to a turn, under the same or another agent.
```json
{ "into": "ana-try", "agent": "assistant-canary", "through_turn": "0198f2a4-..." }
```
The fork gets history and checkpoints up to the cut, and nothing else: no pending events, journal or live feed. The next event sent to it starts a turn on that history, with the target agent’s prompt, tools and model.
## Replay a conversation
[Section titled “Replay a conversation”](#replay-a-conversation)
```sh
newt-replay assistant/ana assistant-canary/ana-replay
```
`newt-replay` sends each turn’s events from `assistant/ana` to the new session, one turn at a time, waits for the turn to finish, and prints what the turn did then and what it did now.
| Flag | Effect |
| --------------- | ----------------------------------------------------- |
| `--from-turn N` | Fork the first N turns in instead of replaying them |
| `--turns N` | Stop after N turns |
| `--url` | The newt server |
| `--token-env` | The variable holding the token (default `NEWT_TOKEN`) |
Caution
A replay runs the target agent’s tools for real. Give the target agent an MCP server that is safe to call: a staging app, or `newt-loadtest mock-app`.
Point the target agent at the `newt-loadtest fake-model` route to replay for free, or at a real model to compare prompts or models.
## Next
[Section titled “Next”](#next)
* [Protocol 5.4 and 5.5](../protocol/#54-get-history).
* [Load testing](../load-testing/) for the fake model and mock app.
# Skills
> Instructions for one kind of task, loaded only when the agent needs them, from the bootstrap or from an MCP server.
A skill is instructions for one kind of task, loaded only when the agent needs it. It keeps the system prompt short and the cache warm.
## Skills in the bootstrap
[Section titled “Skills in the bootstrap”](#skills-in-the-bootstrap)
```json
"skills": [
{ "name": "writing", "description": "How to word a message to the person.", "content": "Short sentences. Lead with the answer." }
]
```
newt adds an index of names and descriptions to the system prompt:
```text
Load a skill with load_skill before doing what it covers.
- writing: How to word a message to the person.
```
and gives the agent a built-in `load_skill` tool. The index changes only with the bootstrap version, so it stays in the cached prefix. A loaded skill’s text stays in that turn’s scratch and never enters history.
## Make a tool wait for a skill
[Section titled “Make a tool wait for a skill”](#make-a-tool-wait-for-a-skill)
Every tool call carries `newt/skills_loaded`, the skills the agent loaded this turn. A handler can refuse an action until the right skill was read, for example a refund tool that needs the `refunds` skill first.
## Skills served over MCP
[Section titled “Skills served over MCP”](#skills-served-over-mcp)
Experimental
This follows SEP-2640, a draft MCP extension.
An MCP server that declares `io.modelcontextprotocol/skills` can serve skills in the Agent Skills format instead of the bootstrap.
* newt lists them on connect and offers each as `{server}/{skill path}`: `skill://acme/billing/refunds/SKILL.md` on server `app` becomes `app/acme/billing/refunds`.
* Nothing is fetched until the model loads one. Then newt reads `SKILL.md` and checks its size, `sha256` digest and frontmatter against the listing.
* A skill’s other files are readable through `load_skill` with `file`, each checked the same way.
A file that fails a check never reaches the model. [Protocol 2.6](../protocol/#26-skills-served-over-mcp) has the full rules.
# Stability
> What newt promises within 1.x, what minor releases may add, and what is outside the promise.
From 1.0 newt follows semantic versioning. Within 1.x, nothing below changes in a way that breaks a working setup.
## Covered
[Section titled “Covered”](#covered)
* **The event API** ([Protocol, section 5](../protocol/#5-event-api-called-by-your-application)): paths, fields, status codes.
* **The context-provider protocol** and the `newt/*` MCP `_meta` fields ([Protocol, sections 1 to 4](../protocol/)).
* **The `server.toml` keys** and their defaults ([Configuration](../configuration/)).
* **The database.** newt applies its own migrations on start, and within 1.x they only add tables, columns and indexes. Upgrading is replacing the binary. Downgrading is not supported.
## What a minor release may add
[Section titled “What a minor release may add”](#what-a-minor-release-may-add)
Optional fields, endpoints, live feed event types and config keys. Clients should ignore fields they do not know.
## Outside the promise
[Section titled “Outside the promise”](#outside-the-promise)
* **Experimental features.** A minor release may change them, and the [changelog](../changelog/) says so. The list is under [Status](../overview/#status).
* The Rust crates, which are not published, and their APIs.
* The JSONL event file and the OTLP span attributes.
# Tracing
> Read every turn from a local JSONL file with newt-report, or export one OTLP trace per turn to Langfuse or any collector.
Every turn emits one typed event stream. Sinks read it: a local JSONL file, the live feed, and OTLP traces.
## Locally: the JSONL file and `newt-report`
[Section titled “Locally: the JSONL file and newt-report”](#locally-the-jsonl-file-and-newt-report)
```toml
[events]
jsonl = "newt-events.jsonl"
capture_content = false
```
The file leaves out message content unless `capture_content = true`. Run `newt-report newt-events.jsonl` for timing and flags per turn, and a **cache report card**: the cached share of all input and, for every call that read under half from cache, what broke it.
| Miss cause | Example |
| ----------------------------------- | ----------------------------------------------------------------------------------------- |
| First call of the session | Expected once |
| A changed piece of the prefix | `tool \`search\` changed, about 6120 bytes into the prefix\` |
| History rewritten from message n | Usually compaction |
| An idle gap past the cache lifetime | The person came back later |
| Routing | Prefix and history unchanged, no gap: the provider sent it to a machine without the cache |
`--prices input,cached,output` adds the cost.
## In production: OTLP traces
[Section titled “In production: OTLP traces”](#in-production-otlp-traces)
```toml
[tracing]
endpoint = "https://cloud.langfuse.com/api/public/otel/v1/traces"
profile = "langfuse"
headers_env = "NEWT_OTLP_HEADERS"
capture_content = true
```
newt exports one trace per turn over OTLP/HTTP JSON:
* an `invoke_agent` root span;
* a `chat` span per model call, with token usage, timings and the messages new to that call;
* an `execute_tool` span per tool call.
A session’s turns share `gen_ai.conversation.id`. The `langfuse` profile also sets the Langfuse session, observation types and usage details. Span ids derive from session, turn and call, so a re-export overwrites rather than duplicates.
Content is exported by default
`[tracing] capture_content` defaults to `true`, unlike `[events]`. Set it to `false` to export only timings, token counts and tool names, with the session key hashed and error text replaced by `failed`.
## Your spans under the turn
[Section titled “Your spans under the turn”](#your-spans-under-the-turn)
Every tool call carries a `traceparent` in its `_meta`. Parent your own spans on it to see them inside the turn’s trace. Library users can add an `Enricher` (extra attributes per session) and a `Redactor` (runs over every captured message before it leaves the process).
## Next
[Section titled “Next”](#next)
* [Configuration: `[tracing]`](../configuration/#tracing) and [`[events]`](../configuration/#events).
# Changelog
> All notable changes are recorded here.
All notable changes are recorded here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and newt follows [semantic versioning](https://semver.org/) from 1.0; what that covers is on the [Stability](../stability/) page.
## \[Unreleased]
[Section titled “\[Unreleased\]”](#unreleased)
## \[1.0.0] - 2026-11-13
[Section titled “\[1.0.0\] - 2026-11-13”](#100---2026-11-13)
The first release.
### Added
[Section titled “Added”](#added)
* **Runtime.** Turn loop with the frozen prefix, history and per-turn scratch layers, and retention classes (`ephemeral`, `durable`, `outward`) that decide what reaches history. Steering of a running turn by new events, a batch tool, and a crash-safe turn journal with exactly-once outward calls through idempotency keys. A Postgres runner with fenced leases, `NOTIFY` wakeups, a periodic sweep, retry backoff and dead-lettering. Events API with read receipts and a live SSE feed.
* **Providers.** Streaming clients for the Anthropic Messages, OpenAI Responses and OpenAI Chat Completions protocols (the last for vLLM, Ollama, OpenRouter and other compatible hosts), with per-route concurrency limits that reserve a slot for interactive turns, one retry of a retryable error before anything has streamed, fallback along a model alias’s route chain, and a 10 minute deadline per model call. Prompt cache keys shared across an agent’s sessions (`runner.cache_key_buckets`). By default (`"auto"`) newt measures each agent’s model calls a minute and spreads its sessions over just enough keys to keep each under 12 a minute, with jump consistent hashing so a new key moves few sessions.
* **Workspace.** A reactive workspace of pages fed by a context provider: invalidation by key prefix, refetch on read, page data returned by tools, and lazy loading that runs the first model call while pages load in the background. Pinned turn context that joins the cached prefix.
* **Context provider.** Bootstrap per version, per-turn context (`instructions`, `constraints`, `pinned`) and page refetch over HTTP. Skills as a first-class feature, loaded on demand with `load_skill`, and visible to tools through `newt/skills_loaded`.
* **MCP.** Tools from MCP servers over Streamable HTTP, with the `newt/*` `_meta` fields for retention, invalidation, idempotency, summaries and page data. Speaks MCP `2026-07-28` with a fallback to `2025-11-25`. Tools sorted by name per server so the cached prefix stays stable. Relists tools after `notifications/tools/list_changed` over `subscriptions/listen`. A call stays alive while it reports progress (90 seconds quiet, 10 minutes total) and is cancelled when abandoned. Each call carries the `traceparent` of its tool span, and its progress notifications reach the live feed as `tool_progress`. Skills served over MCP (SEP-2640): listed on connect as `{server}/{skill path}`, fetched only when loaded, checked against the listing’s digest, size and frontmatter, cached, with their supporting files readable through `load_skill`’s `file`. An agent can be one MCP server and nothing else (`[agents.x] mcp = ...`): its instructions are the server’s, its workspace pages are the session’s resources, and `notifications/resources/updated` over `subscriptions/listen` marks a page stale.
* **Approvals and tasks.** A tool that answers `input_required` with elicitations becomes a question to the person: the model asks, then calls the tool again with `_input` answers that newt sends as MCP input responses, with no state stored in between. Tools that run as MCP tasks are polled for up to an hour.
* **Turn guards.** `max_turn_tokens` in the bootstrap: past it the model is told to finish, and one more call that does not end the turn fails it. Recitation, experimental and off by default: with `recite` in the bootstrap, a `` note restates the turn’s events on long turns, in scratch only. An eval (`examples/recitation_eval.rs`) found no gain on a strong model and 1 to 12% more input tokens.
* **Replay and fork.** `GET .../history` and `POST .../fork` (a new session, under the same or another agent, with a copy of the history up to a turn). `newt-replay` sends a session’s events to a fork turn by turn and prints what each turn did then and now.
* **Compaction.** Checkpoints written by the agent’s own model, with commitments that cannot be dropped silently, a kept tail of recent turns, and raw history left in place. The built-in `remember` tool for durable facts.
* **Observability.** One typed event stream feeding a JSONL file, the live feed and an OTLP/HTTP sink (one trace per turn, with a Langfuse profile, an enricher and a redactor). `newt-report` summarises timing and flags from the JSONL file, and prints a cache report card: cached share, cost at `--prices`, and for every miss the cause (first call, model change, the first changed piece of the prefix with its offset, history rewritten from message n, compaction, idle past `--ttl`, or routing). Prefix and history fingerprints carry a hash per tool and per message for it.
* **Load testing.** `newt-loadtest` with a fake model that speaks the OpenAI Responses stream and simulates prompt caching, a mock context provider and MCP server, and a driver that reports read receipt, turn latency, model calls, cache rate and tool calls.
* **Embedded Postgres.** `database = { embedded = "" }` runs newt’s own Postgres (downloaded once, data in ``), so newt needs no database setup on one machine. On by default through the `embedded-postgres` feature.
* **Profiling.** Model call events carry their prompt cache key, and the cache report card names a session that moved to another key. Accepting an event, claiming a session and loading history report how long they waited for a database connection; history and compaction state each use one connection instead of two or three. The fake model writes a cache log (`--cache-log`) of where each request went and which of its blocks the machine held or could read.
* **Turn start and cache keys.** Starting a turn takes three connections from the pool instead of five: the next turn’s events are read on one, and the turn’s start is written and its history read on another. A session keeps its cache key while its prefix is still cached and the key is not crowded, so adding keys moves no one who is fine where they are.
* **Benchmark.** `scripts/bench.sh` runs the fake model, the mock app and newt and reports load, CPU, memory and the cache report card, with no API key, in a `steady` scenario paced like people and a `burst` worst case; method in [Benchmark](../benchmark/).
* **Demo.** `newt-demo`, a personal assistant that serves a context provider and MCP tools in one process. Its MCP server is also a complete agent on its own (instructions, resources with update notifications, a skill served over MCP), run as `assistant-mcp`. Its MCP server also dogfoods approvals (`clear_tasks` asks first) and tasks (`weekly_review`, polled until done). A Dockerfile with every binary and `deploy/compose.yaml`, which runs Postgres, the demo, the fake model and newt with no API key.
* **Retention.** `[retention]` prunes consumed events after `inbox_days` (30) and live feed frames after `live_days` (7), hourly and in batches. `DELETE /v1/agents/{agent}/sessions/{key}` deletes a session and everything stored for it, and answers 409 while a turn holds its lease. History is never deleted on its own.
* **Several instances.** A test runs two runners against one Postgres and checks that every event reaches history exactly once and every model call belongs to one committed turn.
* **Releases.** A `v*` tag builds `newt`, `newt-report` and `newt-replay` for Linux x86_64 and macOS arm64, pushes the image to `ghcr.io/julianbouchard/newt`, and publishes a GitHub release with this changelog’s section as notes.
* **Workspace setup.** Cargo workspace with `newt-core`, `newt-server`, `newt-demo` and `newt-loadtest`; dual `Apache-2.0 OR MIT` licensing; CI running format, clippy, tests, `cargo deny`, `cargo machete` and a boot smoke test; the newt logo.
# Contributing
> Thanks for looking.
Thanks for looking. newt is early and changes quickly, so open an issue before starting anything large.
## Setup
[Section titled “Setup”](#setup)
You need the Rust toolchain pinned in `rust-toolchain.toml` (1.99.1, with clippy and rustfmt; rustup installs it on first use) and Docker for Postgres.
```sh
docker compose up -d --wait # Postgres 18 on localhost:55432
export DATABASE_URL=postgres://newt:newt@localhost:55432/newt
export NEWT_DATABASE_URL=$DATABASE_URL
```
* `DATABASE_URL` is read by the tests: each `#[sqlx::test]` creates a throwaway database from it and applies `migrations/`.
* `NEWT_DATABASE_URL` is the variable `server.example.toml` names for the server’s own connection (`database.url_env`).
Run the server against the example config:
```sh
NEWT_CONFIG=server.example.toml cargo run -p newt-server
```
The example config also needs `OPENAI_API_KEY`, `ANTHROPIC_API_KEY` and `ASSISTANT_TOKEN` set (any value works until a turn calls the model). The README walks through the demo.
## Checks
[Section titled “Checks”](#checks)
CI (`.github/workflows/ci.yml`) runs these against Postgres 18, in this order. Run them locally before you push:
```sh
cargo fmt --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
cargo deny check # licences, yanked crates, wildcard versions, unknown registries
cargo machete # unused dependencies
```
CI then builds the release binary, starts it with `server.example.toml`, waits for `/healthz` to return 200 and sends it SIGTERM, which must exit cleanly. `cargo deny` and `cargo machete` are installed with `cargo install cargo-deny cargo-machete`.
## Code rules the compiler enforces
[Section titled “Code rules the compiler enforces”](#code-rules-the-compiler-enforces)
These come from `[workspace.lints]` in `Cargo.toml`. With `-D warnings`, every warning below fails CI.
* No `unsafe` (`forbid`).
* No `unwrap()`, `panic!`, `todo!`, `unimplemented!` or `dbg!` (`deny`). `expect()` is a warning: use it only for a true invariant and say why in the message. Tests may unwrap by opening the file with `#![expect(clippy::unwrap_used, reason = "...")]`.
* No `println!` or `eprintln!` (`deny`). Use `tracing`.
* No holding a `Mutex` guard or a `RefCell` borrow across `.await` (`deny`).
* No `#[allow(...)]` (`deny`). Suppress a lint with `#[expect(lint, reason = "...")]`; the reason is required.
* Clippy `all` and `pedantic` are on. Three pedantic lints are switched off: `module_name_repetitions`, `missing_errors_doc`, `must_use_candidate`.
* Public types implement `Debug` (`missing_debug_implementations`).
* `#[must_use]` results must be used (`unused_must_use` is `deny`).
* Large futures, `Rc>` and `clone()` on reference-counted pointers are warnings.
## Style the compiler does not check
[Section titled “Style the compiler does not check”](#style-the-compiler-does-not-check)
* Errors: `thiserror` enums in `newt-core`, `anyhow` with `.context(...)` in `newt-server`. Do not drop the source error.
* Fail loudly on bad config or a bad provider response instead of falling back.
* Prefer concrete types. Add a trait only where the design already has a seam (`EventSink`, `Model`, `ToolHost`, `Journal`).
* Do not block the async runtime. Use tokio’s APIs or `spawn_blocking`.
* Comments only for a constraint the code cannot express.
* A new dependency needs a reason in the pull request, and `cargo deny check` must pass.
## Tests
[Section titled “Tests”](#tests)
Tests assert behaviour on inputs. A bug fix comes with a test that fails without the fix. See the README’s “Running the tests” section.
## Pull requests
[Section titled “Pull requests”](#pull-requests)
* One change per pull request, with a description of what changed and why.
* Rebase on `main` and run the checks above first.
* If you change the context-provider protocol, the MCP `_meta` fields or a config key, update [Protocol](../protocol/) or [Configuration](../configuration/) in the same pull request.
* By contributing you agree your work is licensed as `Apache-2.0 OR MIT`, like the rest of the project.
For security issues, see [Security](../security/). Please follow `CODE_OF_CONDUCT.md`.
# Running the tests
> Run newt's test suite against a throwaway Postgres, and the checks CI runs.
```sh
docker compose up -d --wait
export DATABASE_URL=postgres://newt:newt@localhost:55432/newt
cargo test --workspace
```
* Tests in `newt-server`, `newt-demo` and `newt-loadtest` use Postgres: each `#[sqlx::test]` creates a throwaway database from `DATABASE_URL` and applies `migrations/`.
* The `newt-core` tests need no database.
* No test calls a real model provider.
CI also runs:
```sh
cargo fmt --check
cargo clippy --workspace --all-targets -- -D warnings
cargo deny check
cargo machete
```
See [Contributing](../contributing/) for setup and code rules.
# Benchmark
> scripts/bench.sh [sessions] [turns] [steady|burst] measures newt under load with no model provider and no API key.
`scripts/bench.sh [sessions] [turns] [steady|burst]` measures newt under load with no model provider and no API key. It needs Postgres (`NEWT_DATABASE_URL`, or `docker compose up -d --wait` for the repo’s dev database) and runs everything else itself.
## What runs
[Section titled “What runs”](#what-runs)
* **The fake model** (`newt-loadtest fake-model --cache-scope routed --machines 4`) speaks the OpenAI Responses stream. Its timings and tool use come from the profile of a production assistant, about 16 tool calls a turn. Prompt caching is simulated per `prompt_cache_key` and spread over four machines once a key passes 15 requests a minute, as OpenAI routes, so a key that is too busy or a rewritten history misses as it would against the real thing. A prompt is readable from the cache 1 second after it was sent: against OpenAI on 2026-11-12 a second request with the same prefix hit the cache every time it started 1.1 s or more after the first.
* **The mock app** (`newt-loadtest mock-app`) is a context provider with a production-sized prompt and workspace, and an MCP server whose tools have that assistant’s latencies and error rates.
* **newt**, built in release mode, with default runner settings (`cache_key_buckets = "auto"`).
* **The driver** (`newt-loadtest run`) follows each turn on the live feed. In the default `steady` scenario sessions start at random over 60 seconds and a person pauses 20 to 90 seconds between messages, so each session makes a few model calls a minute, as people do. The `burst` scenario starts every session at once and sends the next message 2 seconds after a turn ends: about 20 model calls a minute per session, past what one cache key holds, and every turn start at the same moment. It is the worst case for the database pool and the cache, not what people do.
## What it reports
[Section titled “What it reports”](#what-it-reports)
* From the driver: time to the read receipt, time until the turn starts, turn duration, model calls and tool calls per turn, the cached share of input, and failed turns.
* newt’s CPU time and resident memory at the end of the run, and how many warnings it logged.
* The cache report card from `newt-report`: the cached share of all input, and every cache miss with its cause, including a session moved to another cache key.
* The fake model’s cache log (`cache.jsonl` in the run directory): one line per request with its cache key, the machine it went to and the key’s home machine, the key’s requests in the last minute, and how many leading prompt blocks that machine held, could read, or did not have.
* Time spent waiting for a database connection when accepting an event, claiming the session and loading history, in `newt-report --summary`.
Turn duration is dominated by the fake model’s simulated latency and the mock tools, so it says whether newt adds waiting, not how fast a real model is. The numbers that are newt’s own are time to read receipt, time to turn start, the cached share and the miss causes, and CPU and memory per session.
## Reading a result
[Section titled “Reading a result”](#reading-a-result)
Run it on a quiet machine: other load shows up as newt CPU time and as slower Postgres. Use a fresh database: the same code read a p90 receipt of 1,080 ms on a long-used dev database and 307 ms on a new one. Compare runs of one commit against another at the same session count, not against numbers from another machine. A failed turn, a miss labelled anything but `first call of the session`, or warnings in newt’s log are worth a look before any timing.
## Results
[Section titled “Results”](#results)
200 sessions, 3 turns each, on an Apple M5 Pro (15 cores, 24 GB) with Postgres 18 in OrbStack, on 2026-11-12. The same machine ran the fake model, the mock app and the driver.
| | `69f29b5` | `0152f99` |
| ------------------------------- | ------------- | ------------- |
| Turns completed | 600 of 600 | 600 of 600 |
| Model calls | 3,879 | 3,887 |
| newt CPU time | 1:56 | 0:12 |
| newt resident memory | 25 MiB | 26 MiB |
| Read receipt p50 / p90 | 3 / 1,080 ms | 4 / 285 ms |
| Event to turn start p50 / p90 | 12 / 2,044 ms | 13 / 586 ms |
| Turn duration p50 / p90 | 16.7 / 28.2 s | 16.3 / 27.4 s |
| Cached share of input | 76.0% | 77.9% |
| Misses: first call of a session | 200 | 200 |
| Misses: routing | 117 | 117 |
| Warnings in newt’s log | 0 | 0 |
What changed between the two:
* **CPU.** At `69f29b5` every live feed and running turn woke for every notification on the instance; a profile showed most of newt’s time waiting on that one channel’s lock. `0673c06` wakes only the session a notification is for.
* **The p90 read receipt and turn start** come from the first turn, when all 200 sessions post in the same moment. After the first turn both are under 15 ms in both runs. `0152f99` opens every database connection at boot instead of growing the pool during that burst.
* **Routing misses** happen in the first minute, while auto cache keys grow from one key to about 130 and sessions move to new keys, each of which the fake model routes to another of its four machines. After the first 80 seconds there are almost none. `18cc08f` raised the key cap from 64 to 256 so that at this rate (about 1,600 calls a minute) each key stays under OpenAI’s \~15 a minute; the fake model’s four machines cannot show that difference.
Turn duration is the fake model’s and the mock tools’ simulated latency, as above. One MCP call timed out after 90 seconds in a run before `0152f99`: the mock app’s latency tail, not newt.
## Per milestone
[Section titled “Per milestone”](#per-milestone)
The same harness (HEAD’s fake model, mock app and driver), newt built at each milestone commit, a fresh database per run, on 2026-11-12. The oldest and newest commits ran twice to show the noise. Score: the mean of four parts, each 0 to 100: turns completed; read receipt p90 (100 ms to 3 s) and turn start p90 (250 ms to 5 s); cached share of input (50% to 90%); CPU per model call (2 ms to 50 ms) and memory per session (0.25 MiB to 5 MiB). Latency, CPU and memory are scored on a log scale between those ends.
| Commit | Milestone | Score | Read receipt p90 | Turn start p90 | Cached | newt CPU per model call |
| --------- | ----------------------------- | ----------- | ---------------- | -------------- | ------------ | ----------------------- |
| `d7e6e89` | Runtime complete | 74.9 / 71.0 | 280 / 330 ms | 864 / 2,243 ms | 80.5 / 80.9% | 28.0 / 24.5 ms |
| `4a77802` | Hot path | 77.0 | 227 ms | 576 ms | 80.3% | 29.3 ms |
| `81e6a76` | Shared cache keys | 73.9 | 250 ms | 569 ms | 75.9% | 29.7 ms |
| `2c7c472` | Hardening | 70.7 | 410 ms | 789 ms | 75.9% | 29.9 ms |
| `0f992bb` | MCP 2026-07-28 | 75.0 | 225 ms | 481 ms | 75.9% | 29.7 ms |
| `69f29b5` | MCP-native agent | 72.2 | 307 ms | 716 ms | 75.7% | 28.9 ms |
| `80d8a89` | Wakeup fix, embedded Postgres | 84.9 / 85.5 | 217 / 195 ms | 499 / 519 ms | 77.7 / 78.4% | 3.1 / 3.1 ms |
All 600 turns completed in every run. The features of six milestones left the score between 71 and 77; the wakeup fix in `0673c06` is the one change that moved it. A move of 3 points or less is within the noise. In this simulator sending no cache key (before `81e6a76`) caches more than shared keys; a replay against the real provider showed the reverse, so the cached share here reflects the fake model’s four machines.
# Security
> Use GitHub private vulnerability reporting on this repository: open the Security tab and choose "Report a vulnerability".
## Reporting a vulnerability
[Section titled “Reporting a vulnerability”](#reporting-a-vulnerability)
Use GitHub private vulnerability reporting on this repository: open the Security tab and choose “Report a vulnerability”. Please do not open a public issue or pull request for a vulnerability.
Include what you found, the version or commit, and steps to reproduce it. Expect an acknowledgement within a few days. There is no bug bounty.
## Supported versions
[Section titled “Supported versions”](#supported-versions)
newt is pre-1.0 and not published to crates.io. Only the `main` branch is supported. Fixes land on `main`; there are no backports.
## Threat model
[Section titled “Threat model”](#threat-model)
This is what the code does today. It is not a certification.
### What is trusted
[Section titled “What is trusted”](#what-is-trusted)
* **The deployment.** The `newt` process, its Postgres database, the config file and the environment variables it names.
* **Context providers.** A provider decides the agent’s instructions, model alias, skills, workspace pages and which MCP servers newt connects to (`crates/newt-server/src/context.rs`). Anything a provider returns is treated as the operator’s own configuration.
* **MCP servers.** Newt calls the tools they list and believes the `_meta` they return: retention class, invalidations, summaries and pages (`crates/newt-server/src/mcp.rs`).
A compromised provider can point `mcp_servers` at any URL with `auth: "agent_token"`, and newt will send the agent’s bearer token there. Keep providers and MCP servers inside your own trust boundary.
### One token per agent
[Section titled “One token per agent”](#one-token-per-agent)
Each `[agents.]` entry names one environment variable (`token_env`). That one token:
* authenticates callers of newt’s event API for that agent (`Authorization: Bearer`, `crates/newt-server/src/http.rs`);
* is sent by newt to the agent’s context provider on every request (`context.rs`);
* is sent to an MCP server only when its bootstrap entry says `auth: "agent_token"` (`mcp.rs`).
The token is not scoped to a session: it grants access to every session of that agent. Newt compares SHA-256 digests of the tokens in constant time, so the check does not leak the token or its length (`same_token` in `http.rs`). An unknown agent name and a wrong token both return 401. Newt serves plain HTTP and does not terminate TLS; put it behind something that does. Newt refuses to start if a token variable is unset or empty.
### Model output is untrusted
[Section titled “Model output is untrusted”](#model-output-is-untrusted)
Model output reaches the world only through tools.
* Newt does not check tool arguments against a tool’s input schema; it only requires a JSON object (`mcp.rs`). Handlers must validate their own input.
* `constraints` from a provider are text shown to the model for one turn. They do not stop a tool call. Enforce anything that must hold in the tool handler, as the README says.
* Every tool call carries an idempotency key. Outward tools (`newt/retention: "outward"`) must declare `newt/idempotent: true`, or newt refuses to load the tool set (`mcp.rs`, `tool_def`).
* An outward call is journaled before it runs, runs after the batch’s other calls, and at most one per batch is accepted. If the journal write fails, the call is not sent. During compaction no outward call runs (`crates/newt-core/src/batch.rs`).
* Newt cannot make a remote effect exactly-once by itself. After a crash it may call an outward tool again with the same key, and the handler must treat a repeated key as a no-op. See [Protocol](../protocol/).
### Content capture
[Section titled “Content capture”](#content-capture)
* Message content is stored in Postgres: inbox payloads, history, checkpoints and the turn journal hold event text, model output and tool output. Protect the database accordingly. `events.capture_content` does not affect it.
* The JSONL event file (`[events] jsonl`) is off unless a path is set. When it is set, message content is removed from events unless `[events] capture_content = true` (default `false`; `crates/newt-core/src/sink.rs`, `event.rs`).
* OTLP export exists only if a `[tracing]` section is present. Inside that section, `capture_content` defaults to `true`: the messages new to each model call and its response are exported. Set `capture_content = false` to export timings, token counts and tool names only; that setting also hashes the session key used as the conversation id and replaces error text with `failed` (`crates/newt-core/src/otlp/spans.rs`). Library users can add a `Redactor` that runs over captured content.
* The live feed (`/v1/agents/{agent}/sessions/{key}/live`) carries event ids, tool names, counts and error text. It does not carry message text.
* Operator logs (`RUST_LOG`) can include error messages from providers and MCP servers.
### Not covered
[Section titled “Not covered”](#not-covered)
* Rate limiting on the event API. Event bodies are capped at 2 MiB (413 above that); put a proxy in front for per-client limits.
* Isolation between agents sharing one database and one process.
* Anything about the model providers’ own handling of your data.