Search

↑↓ to move↵ to openesc to closetop 6 documentation results

Book a demoTry TokenTriage

In development · Explore the product preview. Join the launch list or book a walkthrough.

Browse docs

Report outcomes about your requests

Send your own systems' scores about gateway requests, read them back with their match state, and tell them apart from feedback and Quality evaluations.

How-to guideLast updated

On this page

Your helpdesk knows which answers closed a ticket. Your test harness knows which generated query ran. The gateway saw the same requests and knows only what they cost and how long they took.

A reported outcome carries that knowledge back. Your system sends a small scalar claim about one request it made through a gateway: a value, a pass, or both, for a named metric. The service stores it with its provenance and, later, matches it to the request the gateway recorded.

This page covers the hosted customer API through HTTP, either SDK and the tokentriage cloud CLI. If a person rated an answer inside your application, you want app feedback instead; the next section says which is which.

Feedback, reported outcomes and Quality evaluations

Three features attach a judgement to a request, and each one answers a different question. Picking the wrong one mixes claims that should stay apart.

App feedback Reported outcome Quality evaluation
who judges a person using your application your own system: a helpdesk, a test harness, a batch job TokenTriage, running a scorer you declared
what it says up or down for one question about one request a value, a pass, or both, for a registered metric and evaluator graded results for saved test cases against two configurations
credential a ttv_ runtime key holding feedback.write, at the gateway a management key whose access includes observations.write, at the customer API a management key, at the customer API
basis a person’s rating always reported: a claim the service relays and did not observe what the scorer measured
read back as coverage figures, with no per-rating rows one row per observation, with its match state the comparison’s evidence

A reported outcome is not a TokenTriage evaluation. It is stored as your claim, labelled with the class of credential that sent it, and kept apart from feedback. For the evaluations TokenTriage runs itself, see Compare cost and quality before you switch models.

What you need

You need Because
your organization ID (org_…) and workspace ID (t_…) outcomes are submitted and read inside one workspace
a management key in TOKENTRIAGE_MANAGEMENT_KEY it identifies who reported; the body never names an actor
observations.write at the place each outcome names checked per item, against its application, gateway and environment
metadata.read on the workspace to read outcomes back
the request’s ID, as the gateway returned it in x-tt-request-id an outcome is about one logical request
the application, gateway and environment IDs that served it each outcome names its place, and the service checks it

You do not need the gateway’s runtime key or content.read. No outcome carries prompt or completion text.

Write one outcome

An outcome is one JSON object. This one says a support ticket raised from request lreq_2026_09_20_000001 was resolved:

{
  "observation_id": "3f1c6a2e-8b4d-4c7a-9e21-5d0b7a6c1f01",
  "request_id": "lreq_2026_09_20_000001",
  "application_id": "app_ABCDEFGHJKLM",
  "gateway_id": "gw_ABCDEFGHJKLM",
  "environment_id": "env_ABCDEFGHJKLM",
  "metric_id": "support_resolution",
  "metric_version": "1",
  "evaluator_id": "ticket-resolution-webhook",
  "evaluator_version": "webhook-v1",
  "source": "external_reported",
  "value": 1,
  "pass": true,
  "revision": "1",
  "occurred_at": "2026-09-20T12:04:00Z",
  "attribution": "helpdesk:ticket-48213"
}

The IDs are synthetic. support_resolution and ticket-resolution-webhook are a metric and an evaluator from the built-in registry. Each field has one job:

Field What it decides
observation_id the outcome’s identity: a lowercase UUID you mint once and reuse for every revision
request_id the request it is about: letters, digits, _ and -, at most 128
application_id, gateway_id, environment_id the place that served the request; all three are required
metric_id, evaluator_id a registered metric, and a registered evaluator that declares it
metric_version "1" for every metric on this contract
evaluator_version the evaluator’s registered version, such as webhook-v1
source always external_reported
value finite; 0 or 1 for a binary metric, 0 to 1 for a score, a whole number of 0 or more for a count
pass allowed only on a metric that declares a pass floor; send value, pass or both
revision "1" for the first report, up to "5", as a string
occurred_at when the outcome happened, in UTC
attribution optional label for your own correlation: letters, digits and ., _, :, -, at most 128

The server fills in who sent the outcome, its basis and its arrival time. A body naming an actor is turned away with HTTP 422. attribution is kept by the admission ledger and is not returned by a read.

Submit a batch

One submission carries 1 to 50 items in at most 65,536 bytes of JSON. Each observation_id may appear once per submission. The answer names every item, so one bad item does not sink the others.

Over HTTP, wrap the items in {"schema": 1, "items": [...]} and post them:

Not yet executed against a deployment.

$ curl -sS -X POST \
    "$TOKENTRIAGE_BASE_URL/portal/api/v1/customer/v1/organizations/org_ABCDEFGHJKLM/workspaces/t_ABCDEFGHJKLM/observations" \
    -H "Authorization: Bearer $TOKENTRIAGE_MANAGEMENT_KEY" \
    -H 'content-type: application/json' \
    --data-binary @submission.json

An HTTP client stays under both limits itself. The SDKs and the CLI take any number of items and split them, in order, into requests within 50 items and 65,536 bytes each, measured on the bytes they send. They send the requests one after another and return one disposition per item in input order. An item too large for any request is turned away before anything is sent.

From either SDK, with the item above saved in outcomes.json as a one-element array:

import json
import os
from tokentriage.customer import CustomerClient, UnsupportedError

client = CustomerClient(os.environ["TOKENTRIAGE_BASE_URL"],
                        os.environ["TOKENTRIAGE_MANAGEMENT_KEY"])
with open("outcomes.json", encoding="utf-8") as handle:
    items = json.load(handle)
try:
    receipt = client.observations.submit("org_ABCDEFGHJKLM", "t_ABCDEFGHJKLM",
                                         items)
except UnsupportedError as error:  # HTTP 501: no reported-outcome admission here
    print(type(error).__name__, error.status)
    print(error)
else:
    for d in receipt["items"]:
        print(d["observation_id"], d["state"], d.get("reason", "-"))
    print(receipt["match"], "over", receipt["requests"], "request(s)")

Neither program has been run against a deployment. A control plane with no reported-outcome admission answers HTTP 501, which both clients raise as UnsupportedError.

From the CLI, --file takes either a bare array of items or a whole submission document:

Not yet executed against a deployment; output omitted.

$ tokentriage cloud observation submit --file outcomes.json \
    --org org_ABCDEFGHJKLM --workspace t_ABCDEFGHJKLM \
    --base-url https://app.example.com

The next section shows what it prints.

Read a partial batch

The receipt holds one disposition per item and a match that is always pending: the request is matched later, when a read asks. An item is accepted (stored now), duplicate (stored earlier; nothing changed), conflict or invalid. The last two carry a reason, and neither is stored.

This receipt answers four items. The first is new, the second was sent before, the third reuses a stored revision with a different value, and the fourth names metric_version "v1":

Not yet executed against a deployment.

{
  "schema": "customer/v1",
  "request_id": "req_01J9Z2K5Q7X3M8N4P6R1T2V5W9",
  "kind": "outcome-receipt",
  "data": {
    "schema": 1,
    "items": [
      { "observation_id": "3f1c6a2e-8b4d-4c7a-9e21-5d0b7a6c1f01", "state": "accepted" },
      { "observation_id": "3f1c6a2e-8b4d-4c7a-9e21-5d0b7a6c1f02", "state": "duplicate" },
      { "observation_id": "3f1c6a2e-8b4d-4c7a-9e21-5d0b7a6c1f03", "state": "conflict",
        "reason": "revision_conflict" },
      { "observation_id": "3f1c6a2e-8b4d-4c7a-9e21-5d0b7a6c1f04", "state": "invalid",
        "reason": "unknown_vocabulary" }
    ],
    "match": "pending"
  }
}

The HTTP answer wraps the receipt in the customer API’s envelope; the SDKs return the receipt itself, with requests added. The CLI prints a table and a tally, then exits 3 because two items were not stored:

Not yet executed against a deployment.

$ tokentriage cloud observation submit --file outcomes.json \
    --org org_ABCDEFGHJKLM --workspace t_ABCDEFGHJKLM \
    --base-url https://app.example.com
OBSERVATION                           STATE      REASON
3f1c6a2e-8b4d-4c7a-9e21-5d0b7a6c1f01  accepted   -
3f1c6a2e-8b4d-4c7a-9e21-5d0b7a6c1f02  duplicate  -
3f1c6a2e-8b4d-4c7a-9e21-5d0b7a6c1f03  conflict   revision_conflict
3f1c6a2e-8b4d-4c7a-9e21-5d0b7a6c1f04  invalid    unknown_vocabulary
1 accepted, 1 duplicate, 1 conflict, 1 invalid
4 items in 1 request(s); match is pending until the request is indexed

That table was rendered with the CLI’s own format strings over this synthetic receipt. On stderr the CLI adds one line:

cloud: 2 item(s) were answered conflict or invalid and were not stored; the other items stand
CLI exit Means
0 every item is accepted or duplicate
3 some item is conflict or invalid
1 a request failed; the items answered before it are still printed

Each per-item reason names one fix:

reason State What it means What to do
unknown_vocabulary invalid the metric or evaluator is not registered, the evaluator does not declare the metric, a version is not the registered one, metric_version is not "1", or source is not external_reported correct the names or versions
invalid_value invalid a field is missing or malformed, the value is outside the metric’s range, pass was sent for a metric with no pass floor, or neither value nor pass was sent correct the item
oversize invalid a field is longer than 128 bytes shorten it
outside_window invalid occurred_at is outside the admission window report closer to when it happened; the window is the last seven days
scope_not_authorized invalid the application, gateway and environment are not a place your access can report on check the three IDs, or ask for observations.write there
revision_conflict conflict this revision is stored with a different body send the change as the next revision
attribution_mismatch conflict a later revision changes a field the first revision fixed keep those fields as revision 1 sent them

Correct an outcome

A correction is the next revision of the same observation_id, not a new outcome. Revision 1 fixes the request, the place, the metric and evaluator with their versions, the attribution and occurred_at. A later revision may change only value and pass, up to revision 5.

Send each revision in its own submission, because one submission names each observation_id once. Revisions may arrive in any order. A read reports the highest stored revision as current, so a late revision 2 does not overwrite a revision 3 that arrived first.

Identity is per sender. Two credentials reporting the same observation_id create two outcomes, told apart on read by their actor_ref.

Read outcomes back

A read lists the outcomes whose occurred_at falls in a half-open UTC window of at most 400 days, optionally for one request. By default it returns each outcome’s current revision; revisions returns every stored revision with its current flag. Each row carries a match state:

match Means
matched the request was found in the same application, gateway and environment
pending the request index has not caught up yet, so this may still become matched
unmatched no such request was found in that place

Over HTTP, post the read to observations/read:

Not yet executed against a deployment.

$ curl -sS -X POST \
    "$TOKENTRIAGE_BASE_URL/portal/api/v1/customer/v1/organizations/org_ABCDEFGHJKLM/workspaces/t_ABCDEFGHJKLM/observations/read" \
    -H "Authorization: Bearer $TOKENTRIAGE_MANAGEMENT_KEY" \
    -H 'content-type: application/json' \
    -d '{"schema":1,"from":"2026-09-20T00:00:00Z","to":"2026-09-21T00:00:00Z"}'

The answer’s data holds read_context, rows, truncated, and next_cursor when truncated is true. To continue, send the same read with cursor set to next_cursor and read_context copied unchanged. A cursor without its read context is rejected. One row, as the contract defines it:

Not yet executed against a deployment.

{
  "observation_id": "3f1c6a2e-8b4d-4c7a-9e21-5d0b7a6c1f01",
  "request_id": "lreq_2026_09_20_000001",
  "application_id": "app_ABCDEFGHJKLM",
  "gateway_id": "gw_ABCDEFGHJKLM",
  "environment_id": "env_ABCDEFGHJKLM",
  "metric_id": "support_resolution",
  "metric_version": "1",
  "evaluator_id": "ticket-resolution-webhook",
  "evaluator_version": "webhook-v1",
  "source": "external_reported",
  "basis": "reported",
  "actor_class": "service",
  "actor_ref": "roa_0123456789abcdef0123456789abcdef",
  "value": 1,
  "pass": true,
  "revision": "1",
  "current": true,
  "occurred_at": "2026-09-20T12:04:00Z",
  "arrived_at": "2026-09-20T12:04:02Z",
  "match": "matched"
}

basis, actor_class, actor_ref and arrived_at are the server’s. actor_class is person or service. actor_ref is an opaque stored reference to the sender, not derived from its identity, and attribution is absent.

The SDKs follow next_cursor the same way. This loop is the one in the SDK examples (sdk/python/examples/reported_outcomes.py, sdk/ts/examples/reported_outcomes.ts):

Not yet executed against a deployment.

def read_all(client, org, workspace, query, max_pages=10):
    """Every row of the window, following next_cursor with its read_context."""
    page = client.observations.list(org, workspace, query)
    rows = list(page["rows"])
    pages = 1
    while page.get("truncated") and page.get("next_cursor") and pages < max_pages:
        page = client.observations.list(org, workspace, query,
                                        cursor=page["next_cursor"],
                                        read_context=page["read_context"])
        rows.extend(page["rows"])
        pages += 1
    return rows

rows = read_all(client, "org_ABCDEFGHJKLM", "t_ABCDEFGHJKLM",
                {"schema": 1, "from": "2026-09-20T00:00:00Z",
                 "to": "2026-09-21T00:00:00Z"})
for row in rows:
    measure = row.get("value", row.get("pass"))
    print(f"{row['request_id']} {row['metric_id']}={measure} "
          f"rev {row['revision']} "
          f"({row['basis']} by {row['actor_class']}, {row['match']})")

For the row above, each program would print lreq_2026_09_20_000001 support_resolution=1 rev 1 (reported by service, matched).

The CLI follows every continuation itself. --request narrows to one request and --revisions lists every stored revision:

Not yet executed against a deployment.

$ tokentriage cloud observation list \
    --from 2026-09-20T00:00:00Z --to 2026-09-21T00:00:00Z \
    --org org_ABCDEFGHJKLM --workspace t_ABCDEFGHJKLM \
    --base-url https://app.example.com
OBSERVATION                           REQUEST                 REV  CURRENT  METRIC                EVALUATOR                             VALUE  PASS   ACTOR    OCCURRED              MATCH
3f1c6a2e-8b4d-4c7a-9e21-5d0b7a6c1f01  lreq_2026_09_20_000001  1    true     support_resolution@1  ticket-resolution-webhook@webhook-v1  1      true   service  2026-09-20T12:04:00Z  matched
3f1c6a2e-8b4d-4c7a-9e21-5d0b7a6c1f02  lreq_2026_09_20_000002  1    true     support_resolution@1  ticket-resolution-webhook@webhook-v1  0      false  service  2026-09-20T12:09:00Z  pending
Not shown: the request index has not caught up, so a pending match may still become matched (request_index_behind)
2 row(s) across 1 page(s)

That table was rendered with the CLI’s format strings over two synthetic rows. The Not shown: line is the answer’s coverage: request_index_behind says a pending row may still match, and outcomes_not_projected says outcomes admitted recently may not be visible yet.

What can go wrong

A refused submission stores nothing from that request. The statuses are distinct because their remedies are: one says fix the body, one says ask for access, one says wait.

Status type When Python and TypeScript class What to do
422 validation_failed an unknown member (such as actor), a NaN or 1e999 token, a body over 65,536 bytes, schema other than 1, 0 or more than 50 items, or a malformed or repeated observation_id InsightRefusalError fix the body and send it again
403 forbidden no observations.write anywhere in the workspace, or a system credential InsightForbiddenError ask a workspace administrator for a binding that grants it
403 authority_revoked your access changed while the batch was admitted InsightRefusalError resubmit; it is judged under your current access
404 not_found the workspace is not one your key can see: “this organization has no such workspace” CustomerProblemError check the organization and workspace IDs
429 rate_limited too many outcomes from this sender in this workspace; Retry-After names the wait ObservationsRateLimitedError wait retry_after (Python) or retryAfterSeconds (TypeScript), then resubmit
503 dependency_unavailable the store did not answer, or writes are switched off on this deployment ObservationsUnavailableError retry after Retry-After when present; resubmitting is safe
501 not_implemented this control plane has no reported-outcome admission UnsupportedError use a deployment that serves the operation

Neither SDK retries a 429 or a 503 on its own, and the CLI does not either: it prints the Retry-After it received. The two Observations… classes subclass InsightRefusalError and InsightUnavailableError, so a handler for those catches them too. Both are importable from tokentriage.customer (Python) and tokentriage/customer (TypeScript).

Reads have their own set. HTTP 403 means metadata.read does not reach the workspace. HTTP 422 means the read is malformed: the window, a page_size outside 1 to 500, a bad request ID, or a cursor without its read context. HTTP 410 means the read context expired, so start again without cursor and read_context. HTTP 503 means the read could not be answered. Read request coverage and freshness covers read contexts and those answers in depth.

Where the statuses and messages on this page come from

The statuses, type values and sentences come from internal/control/foundation/httpapi/observations.go, the contract internal/central/insightcontract/outcome_submission.go, the vocabulary check internal/evaluator/reported.go, the SDK clients and internal/cli/cloud_observations.go at this page’s date. None of them has been answered by a deployment.

What this does not carry

An outcome is a scalar claim with its provenance. It has no free text, no notes, no rationale and no cost field, so none can be sent. It does not change any request count, cost or feedback coverage figure, and it is not scored or re-graded by TokenTriage. The metrics and evaluators it can name are the registered ones; this contract has no way to add your own.