ultrarouter docs
Guides

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:e2e boots the gateway on http://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 workspace

Step 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_consent off, 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

SymptomCause and fix
The status shows enabled: falseEpisode 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 outcomesReport 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 allTurn 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_foundNo 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 POSTThe 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 verdictTheir openings are identical and they carry no episode handle, so they are one episode. Send a distinct X-UR-Artifact per task.