Self-learning agents
Point an unmodified OpenAI-compatible agent at the gateway and report one outcome per finished episode. The gateway remembers the tasks your agent solved and serves the most similar ones back — with the actions that worked — on every step of new episodes. Retrieval, not training; per-workspace; identifiers redacted.
Episode memory turns the traffic your agent already sends into a private, growing memory of what worked: when your app reports that a task passed, the gateway keeps that episode, and on later tasks it serves your agent the most similar past episodes — with the actions that solved them — as reference data on every step. You change nothing in the agent itself: the same OpenAI-compatible calls, plus one outcome report per finished episode.
Episode memory is a new, pre-evidence capability. Its first held-out confirmation did not confirm that memory built from more verified episodes beats an earlier, smaller memory, and a second study is under way. This page therefore makes no claim that it improves accuracy, speed or cost — it describes the mechanism only. It is retrieval of your agent's own verified episodes; ultrarouter builds no model from them. We turn it on per call type while it is pre-evidence — ask us to enable it for a call type, and you can turn it off yourself at any time (below).
Learned lessons (a playbook distilled from your verified episodes) can also be turned on
self-serve, with a promotion policy that decides when a newly learned version is served — see
Learning controls.
That self-serve path requires the workspace Allow learning opt-in, which grants both corrections
and knowledge consent. The owner uses the dashboard Learning page or Learn onboarding flow; the
session-only equivalent is PUT /v1/workspaces/{id}/self-learning with {"enabled":true}.
Prerequisites
- The stack running. Locally,
pnpm dev:e2eboots the gateway onhttp://localhost:8080. These endpoints are part of the learning preview, which is not on the invite-only hosted gateway (https://api.ultrarouter.io) yet — run the local stack or point at your own deployment. - Corrections consent on. The task and the agent's steps are your workspace's content, so
episode memory only records and serves them when the workspace owner has turned on
corrections_consent(the dashboard Flywheel page, Corrections consent). Revoking it hard-deletes the stored episodes. - A call type with episode memory enabled. Ask us to enable it for the call type whose agent you want to improve (it is staff-granted while pre-evidence).
export UR_BASE_URL="http://localhost:8080"
export UR_API_KEY="ur_sk_live_…" # ur_sk_live_* or ur_sk_test_*Step 1 — point the agent at the gateway, with a key that carries a default call type
Change only the base URL and the key. So your requests stay plain OpenAI — no ultrarouter
field, no header, no SDK change — mint a key with a default call type: the gateway then
attaches that call type to every request on the key. Set it when you create the key
(default_call_type) or later:
curl -sS -X PATCH "$UR_BASE_URL/v1/workspaces/$UR_WORKSPACE_ID/api-keys/$UR_KEY_ID" \
-H "Authorization: Bearer $UR_API_KEY" -H "Content-Type: application/json" \
-d '{ "default_call_type": "my-agent" }' # a slug or id of a call type in this workspaceStep 2 — keep your agent exactly as it is
Each agent step is one chat-completions request carrying the conversation so far. The gateway
groups all the steps of one task into a single episode using the dispatch cluster key it
already computes, so you send no episode id and no header. Every response carries
x-ur-request-id; keep the one from the episode's last step — that is where you report
the outcome.
import OpenAI from "openai";
// Only the base URL and key change — the rest is the stock OpenAI SDK.
const client = new OpenAI({
apiKey: process.env.UR_API_KEY,
baseURL: `${process.env.UR_BASE_URL}/v1`,
});
// Your agent loop, unchanged: one request per step, each carrying the conversation so far.
// `.withResponse()` exposes the raw response so you can read the request id header.
const { data: completion, response } = await client.chat.completions
.create({ model: "openai/gpt-5-mini", messages })
.withResponse();
const lastRequestId = response.headers.get("x-ur-request-id");
// …append completion.choices[0].message, run the tool, step again until the task is done,
// updating lastRequestId each step…import os
from openai import OpenAI
# Only the base URL and key change — the rest is the stock OpenAI SDK.
client = OpenAI(api_key=os.environ["UR_API_KEY"],
base_url=f"{os.environ['UR_BASE_URL']}/v1")
# `with_raw_response` exposes the headers; `.parse()` gives the usual completion object.
raw = client.chat.completions.with_raw_response.create(
model="openai/gpt-5-mini", messages=messages)
last_request_id = raw.headers.get("x-ur-request-id")
completion = raw.parse()
# …append the assistant message, run the tool, step again until the task is done,
# updating last_request_id each step…Step 3 — report one outcome per finished episode
After your own check of the finished task, POST one outcome on the episode's last
request id. channel: "computable" with kind: "oracle_pass" or "oracle_fail" is a
whole-episode teaching signal — an oracle verdict, not a thumbs-up. Only a pass becomes
memory.
// `passed` is your own oracle: a test suite, an exact-match check, a resolved ticket.
await fetch(`${process.env.UR_BASE_URL}/v1/requests/${lastRequestId}/outcomes`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.UR_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
channel: "computable",
kind: passed ? "oracle_pass" : "oracle_fail",
}),
});import requests
# `passed` is your own oracle: a test suite, an exact-match check, a resolved ticket.
requests.post(
f"{os.environ['UR_BASE_URL']}/v1/requests/{last_request_id}/outcomes",
headers={"Authorization": f"Bearer {os.environ['UR_API_KEY']}"},
json={"channel": "computable", "kind": "oracle_pass" if passed else "oracle_fail"},
)Report it the moment your check has run — you do not wait for anything. The gateway
records each step (and the episode it belongs to) just after answering it, so a report can
arrive first; the answer is still 202, with a durable receipt. status: "pending" only
means the step has not been recorded yet — the outcome is applied when it is, and joins its
episode as soon as the episode lands (receipt.episode.status becomes associated). Read
the receipt with GET /v1/outcome-receipts/{id} if you want to watch it settle; send a
source_event_id so a retry after a lost response returns the same receipt instead of a
second outcome. The full receipt contract is in Record outcomes.
That is the whole integration: base URL + a key with a default call type, your agent unchanged, and one outcome per episode.
Several tasks at once with the same opening
Without a header, the gateway groups steps into episodes by their opening — the system
prompt plus the first user message. Two tasks that run at the same time with identical
openings are therefore one episode to it, and the first verdict reported wins for both. If
your agent runs such tasks concurrently (a batch of identical prompts over different data,
say), give each task its own episode handle — the same X-UR-Artifact header (or body
ur.artifact) on every step of that task:
await client.chat.completions.create(
{ model: "openai/gpt-5-mini", messages },
{ headers: { "X-UR-Artifact": `task-${taskId}` } },
);Each handle is its own episode with its own verdict, however many share an opening.
What gets learned — and what never does
Learned. Only episodes your app reports as passed. A passing episode is kept as its task plus the actions that did not fail, and later episodes of the same call type are served the most similar ones (by task text) as reference data after the system messages — the same precedents on every step of a given episode.
Never.
- Only your reported passes teach. A proxy signal, a behavioral click, or the gateway's own mechanical signals never verify an episode — only an oracle, a resolution or a correction outcome you report does.
- The holdout is never touched. A standing fraction of traffic (at least 5%) is never served memory and never recorded, so a fair comparison always exists.
- Only under consent. With
corrections_consentoff, nothing is recorded or served; revoking it hard-deletes the workspace's stored episodes. - Identifiers are redacted. Every served precedent passes through the core PII redactor, so emails, phone numbers, names and the other categories are replaced with typed placeholders before another episode ever sees them.
- Per-workspace, never shared. Your verified episodes are served only within your own workspace — never across workspaces, and never pooled into a shared or cross-workspace model. Episode memory is retrieval of your own history, not training.
See it — and turn it off
Read a call type's episode-memory status (the setting, the lab authorisation, and the
verified / failed / unverified episode counts with the latest verification). These routes take the
ct_… ID, not the slug. Resolve the ID for the my-agent call type used above:
UR_CALL_TYPE_ID="$(curl -fsS "$UR_BASE_URL/v1/call-types" \
-H "Authorization: Bearer $UR_API_KEY" \
| jq -er '.data[] | select(.slug == "my-agent") | .id')"
export UR_CALL_TYPE_ID
curl -sS "$UR_BASE_URL/v1/call-types/${UR_CALL_TYPE_ID:?set the call type ID}/episode-memory" \
-H "Authorization: Bearer $UR_API_KEY"The same figures and a small learning trend (per week, or per day while history is short) appear on the call type's Agent memory panel in the dashboard.
Turn it off at any time — the kill switch is never staff-gated and takes effect within one config window:
curl -sS -X POST "$UR_BASE_URL/v1/call-types/${UR_CALL_TYPE_ID:?set the call type ID}/episode-memory/disable" \
-H "Authorization: Bearer $UR_API_KEY"Troubleshooting
| Symptom | Cause and fix |
|---|---|
The status shows enabled: false | Episode memory is not on for the call type. It is staff-enabled while pre-evidence — ask us to turn it on. |
verified_pass stays 0 after reporting outcomes | Report the outcome on the episode's last request id (x-ur-request-id from the final step), and only oracle_pass becomes memory. A held-out episode is never recorded. |
| Nothing is recorded at all | Turn on corrections_consent (the dashboard Flywheel page, Corrections consent); without it episode memory records and serves nothing. |
The outcome POST answers status: "pending" | Normal when you report right after the response: the step has not been recorded yet. The receipt is applied on its own — nothing to retry. |
The receipt expired with request_not_found | No step with that id was recorded in your workspace within 15 minutes — use the x-ur-request-id from a real step made with a key of this workspace. |
404 on the outcome POST | The id is a gateway-minted req_… from more than 15 minutes ago with no recorded step — use the x-ur-request-id header from a real, recent step on this key. |
| Two concurrent tasks share one verdict | Their openings are identical and they carry no episode handle, so they are one episode. Send a distinct X-UR-Artifact per task. |
Record outcomes
The thesis walkthrough — declare the objective, then record outcomes five ways (instant, delayed, batch, warehouse backfill, flush) over the real SDK and raw REST, and report the moment you have the answer with a durable receipt. Server-assigned provenance, durable idempotent retries, never a body.
Learning controls
Turn learning on for a call type yourself, decide how a learned version becomes the one your agent is served (a randomized evaluation, your own decision, or immediately), and pause, roll back, promote, reject or disable it — with the learning running only on a provider your workspace owns.