Agent value API
Set what counts as value for an agent, record outcomes on its ledger, reverse mistakes, and read its lifetime value.
In short
Six endpoints under /agents/{agent_id}: read and replace the value profile, record and list ledger entries, reverse an entry, and read the agent's lifetime value. For what value tracking is and when to use it, start with Agent value.
All endpoints take the agent's id, the same id the Agents API returns. Reads need the agents:read scope and read access to the agent. Writes need agents:write and write access to the agent. A key without access to the agent is refused with 403, and so is an agent id that does not exist, because access is checked before the agent is looked up. All amounts are in US dollars.
| Endpoint | Scope | What it does |
|---|---|---|
GET /agents/{agent_id}/value-profile | agents:read | Read the profile |
PUT /agents/{agent_id}/value-profile | agents:write | Replace the profile |
POST /agents/{agent_id}/value-events | agents:write | Record an outcome |
GET /agents/{agent_id}/value-events | agents:read | List the ledger |
POST /agents/{agent_id}/value-events/{event_id}/reversal | agents:write | Reverse an entry |
GET /agents/{agent_id}/value | agents:read | Read the lifetime value |
Money crosses the wire as decimal strings such as "12.00", so cents survive the trip. Requests accept strings or numbers; responses always return strings.
Set the value profile
GET /agents/{agent_id}/value-profile returns the profile. An agent that has never had one saved returns the defaults with is_enabled false and updated_at null.
PUT /agents/{agent_id}/value-profile replaces the whole profile. Every field is written, and any you leave out takes its default, so send the full profile each time. Editing prices never changes entries already recorded.
curl -X PUT "https://api.hyperstruck.com/agents/<AGENT_ID>/value-profile" \
-H "Authorization: Bearer <API_KEY>" \
-H "Content-Type: application/json" \
-d '{
"is_enabled": true,
"value_description": "Each support ticket this agent resolves without a person. Record a ticket-resolved when the customer confirms the fix.",
"hourly_rate_usd": "60.00",
"is_auto_estimate_enabled": true,
"minutes_per_applied_learning": "5.00",
"rate_card": [
{
"key": "ticket-resolved",
"label": "Ticket resolved",
"amount_usd": "12.00",
"time_saved_minutes": "20.00",
"description": "Customer confirmed the issue is fixed."
},
{
"key": "escalation-avoided",
"label": "Escalation avoided",
"time_saved_minutes": "90.00",
"description": "A ticket that would have gone to a second-line engineer was closed by the agent."
}
]
}'| Field | Type | Default | Meaning |
|---|---|---|---|
is_enabled | boolean, required | none | Turns tracking on. While off, new entries are refused with 409 and history stays readable. |
value_description | string, up to 2,000 characters | null | What counts as value, in plain words. A blank string is stored as null. |
hourly_rate_usd | decimal above 0, up to 10,000, two decimal places | null | Turns minutes into dollars. null uses the default of 100. |
is_auto_estimate_enabled | boolean | true | Lets the platform write an estimate after a successful run that applied learnings. |
minutes_per_applied_learning | decimal above 0, up to 1,440, two decimal places | null | Minutes the estimate credits for each applied learning. null uses the default of 10. |
rate_card | list of up to 50 units | [] | Named, priced outcomes. Keys must be unique. |
Each rate-card unit:
| Field | Type | Meaning |
|---|---|---|
key | string, required | What callers send as unit_key. Lowercase letters, digits, _, . and -, starting with a letter or digit, up to 64 characters. |
label | string, required | A display name, 1 to 120 characters. |
amount_usd | decimal above 0, up to 10,000,000 | Dollars one unit is worth. Leave out for a unit that only saves time. |
time_saved_minutes | decimal above 0, up to 525,600 | Minutes one unit saves. When amount_usd is left out, dollars are these minutes at the hourly rate. |
description | string, up to 500 characters | When the unit applies, in words another agent can check. |
A unit needs amount_usd, time_saved_minutes, or both. A request with an unknown field, a duplicate key or a unit with neither figure is refused with 422.
The response is the profile, plus the values in force and the guidance:
{
"agent_id": "<AGENT_ID>",
"is_enabled": true,
"value_description": "Each support ticket this agent resolves without a person. Record a ticket-resolved when the customer confirms the fix.",
"currency": "USD",
"hourly_rate_usd": "60.00",
"effective_hourly_rate_usd": "60.00",
"is_auto_estimate_enabled": true,
"minutes_per_applied_learning": "5.00",
"effective_minutes_per_applied_learning": "5.00",
"rate_card": [
{
"key": "ticket-resolved",
"label": "Ticket resolved",
"amount_usd": "12.00",
"time_saved_minutes": "20.00",
"description": "Customer confirmed the issue is fixed."
},
{
"key": "escalation-avoided",
"label": "Escalation avoided",
"amount_usd": null,
"time_saved_minutes": "90.00",
"description": "A ticket that would have gone to a second-line engineer was closed by the agent."
}
],
"updated_at": "2026-10-29T09:15:00Z",
"guidance": "Value tracking is on for this agent.\nWhat counts as value: Each support ticket this agent resolves without a person. Record a ticket-resolved when the customer confirms the fix.\nRate card. When one of these happened, send unit_key and quantity; the price comes from here, so never send a dollar amount for them:\n- ticket-resolved (Ticket resolved): $12.00 and 20 min saved per unit. Use when: Customer confirmed the issue is fixed.\n- escalation-avoided (Escalation avoided): 1.5 h saved (valued at $60.00/hour) per unit. Use when: A ticket that would have gone to a second-line engineer was closed by the agent.\nFor an outcome not on the rate card, send amount_usd with a baseline (what it would have cost without the agent) only when a person, document or system of record stated the figure. To credit time alone, send time_saved_minutes; dollars are derived at $60.00/hour.\nPass the run_id of the learning run that produced the outcome so its learnings are credited, and an idempotency_key naming the outcome so a retry does not count twice.\nDo not record the routine saving of a run yourself: after each successful run that applied learnings the platform estimates 5 min per applied learning.\nRecord value only when this agent's value tracking is on and you have a basis you did not invent: a rate-card unit that matches what happened, or a figure a person, a document or a system of record stated. Never guess a dollar figure; the platform writes its own estimate after successful runs."
}effective_hourly_rate_usd and effective_minutes_per_applied_learning are the figures in force: the agent's own, or the default where the agent's is null.
guidance is plain-language instructions generated from the profile, written for an agent to read before it records value. It says whether tracking is on, repeats the description, lists each rate-card unit with its price, and says which request to send. When tracking is off it says to record nothing. Treat it as text to hand to an agent, not as a structure to parse.
Record an outcome
POST /agents/{agent_id}/value-events writes one ledger entry. It returns 201 with the entry, or 200 with the original entry when the same idempotency_key and body were already recorded.
Send exactly one figure: a rate-card unit, a stated amount with a baseline, or minutes alone.
curl -X POST "https://api.hyperstruck.com/agents/<AGENT_ID>/value-events" \
-H "Authorization: Bearer <API_KEY>" \
-H "Content-Type: application/json" \
-d '{
"idempotency_key": "ticket:SUP-2048",
"unit_key": "ticket-resolved",
"quantity": "1",
"run_id": "support-agent:SUP-2048:attempt-1",
"category": "support_triage",
"note": "Customer confirmed the fix in the ticket thread."
}'| Field | Type | Meaning |
|---|---|---|
idempotency_key | string, required, 1 to 200 characters | Names the outcome. Unique per agent. Keys starting reversal: or auto: are reserved and refused with 422. |
unit_key | string, up to 64 characters | A rate-card unit. Send it alone, or with quantity. |
quantity | decimal up to 10,000, two decimal places | How many units. Defaults to 1. Needs unit_key. |
amount_usd | decimal up to 10,000,000, two decimal places | A dollar figure someone stated. Needs baseline. |
time_saved_minutes | decimal up to 525,600, two decimal places | Minutes saved. Alone, dollars are these minutes at the hourly rate. Beside amount_usd it is recorded as extra context. |
baseline | string, up to 500 characters | What the outcome would have cost without the agent, in words a reviewer can check. Required with amount_usd. |
kind | measured or estimated | Leave it out for measured. Send estimated only to mark your own figure as a guess. |
category | string, up to 100 characters | A label for grouping in the summary. Entries without one group under other. |
note | string, up to 1,000 characters | Free text. |
evidence_url | string, up to 2,000 characters | A link to the evidence. Must start with http:// or https://. |
run_id | string, up to 512 characters | The run_id of the learning run that produced the outcome. Credits what that run was offered. |
learning_ids | list of up to 50 strings | Learnings to credit. |
claim_ids | list of up to 50 strings | Claims to credit. Needs a known run_id. |
occurred_at | timestamp | When the outcome happened. Defaults to now. May be in the past, not the future. A time without an offset is read as UTC. |
How the figure is worked out
| You send | basis on the entry | Dollars | Minutes |
|---|---|---|---|
unit_key where the unit has amount_usd | rate_card | The unit's amount times quantity | The unit's minutes times quantity |
unit_key where the unit has only minutes | rate_card | The minutes at the hourly rate | The unit's minutes times quantity |
amount_usd and baseline | stated | As stated | time_saved_minutes if sent, else 0 |
time_saved_minutes alone | time_derived | The minutes at the hourly rate | As sent |
Amounts are rounded to cents. Each entry copies the price (unit_amount_usd) or the hourly rate it used, so a later profile edit does not rewrite it. One entry is capped at 10,000,000 dollars and 525,600 minutes, including the figure worked out from a unit and quantity; larger outcomes are split across entries.
Retries and idempotency
Reusing an idempotency_key with the same request returns the original entry with 200. "Same" is read by value: "1" and "1.00" are the same quantity, and leaving out kind is the same as sending measured. Reusing the key with a different request is refused with 409 and idempotency_conflict, and detail.id names the entry the key already wrote. A replay is answered before the tracking switch is checked, so a retry still succeeds after tracking is turned off.
Crediting learnings and claims
| You send | Credited |
|---|---|
run_id alone | Every learning and claim that run was offered, up to 50 of each |
run_id with learning_ids or claim_ids | Just those, each of which the run must have been offered. Anything else is refused with attribution_not_offered. |
learning_ids alone | Those learnings, each of which must belong to this agent. Anything else is refused with unknown_learning. |
| Neither | Nothing |
claim_ids always need a run_id that is known. A recent run is known whether or not tracking is on. About a day after a run closes its records are cleared, and its offers are kept for a further year only if value tracking is on for the agent at that point. Keep tracking on while outcomes you want to credit are still to come. An unknown run_id with no learning_ids is refused with run_not_found rather than recorded without credit. With learning_ids, an unknown run_id is ignored and the learnings are checked against the agent's own.
The entry
The response, and every item in the ledger, has this shape:
{
"id": "<ENTRY_ID>",
"agent_id": "<AGENT_ID>",
"kind": "measured",
"basis": "rate_card",
"amount_usd": "12.00",
"time_saved_minutes": "20.00",
"unit_key": "ticket-resolved",
"quantity": "1.00",
"unit_amount_usd": "12.00",
"hourly_rate_usd": null,
"category": "support_triage",
"baseline": null,
"note": "Customer confirmed the fix in the ticket thread.",
"evidence_url": null,
"run_id": "support-agent:SUP-2048:attempt-1",
"learning_ids": ["<LEARNING_ID>"],
"claim_ids": [],
"reverses_event_id": null,
"reversal_reason": null,
"is_reversed": false,
"source": "api",
"recorded_by": { "type": "api_key", "id": "<API_KEY_ID>", "label": "support-agent key" },
"idempotency_key": "ticket:SUP-2048",
"occurred_at": "2026-10-29T09:20:00Z",
"created_at": "2026-10-29T09:20:00Z"
}| Field | Meaning |
|---|---|
kind, basis | How sure the figure is, and how the dollars were worked out. basis is rate_card, stated, time_derived, default_estimate (the platform's estimate) or reversal. |
unit_amount_usd, hourly_rate_usd | The price or rate copied when the entry was written. |
is_reversed | True when a later reversal cancels this entry. |
reverses_event_id, reversal_reason | Set on a reversal entry only. |
source | Which door the entry came through: api, mcp, boundary (the value sent when a run closes), auto (the platform's estimate) or portal. For audit only; it never changes the arithmetic. |
recorded_by | Who wrote it, copied at write time so a renamed key or agent keeps its history. type is agent, api_key, user or platform. label is a readable name: the agent's name, the API key's name, the user's name or email, or Platform estimate. id is the API key or user that wrote the entry, including for agent, whose name is the one the caller gave; it is null for the platform. |
List the ledger
GET /agents/{agent_id}/value-events returns the ledger, newest first by occurred_at.
| Query parameter | Default | Meaning |
|---|---|---|
kind | none | measured or estimated, to list only one kind. |
limit | 25 | Page size, 1 to 100. |
cursor | none | The next_cursor from the previous page. A cursor from a different agent or kind filter is refused with 400. |
curl -H "Authorization: Bearer <API_KEY>" \
"https://api.hyperstruck.com/agents/<AGENT_ID>/value-events?kind=measured&limit=25"The response is { "items": [ ...entries ], "next_cursor": "..." }; next_cursor is null on the last page. Reversal entries appear in the list beside the entries they cancel.
Reverse an entry
POST /agents/{agent_id}/value-events/{event_id}/reversal cancels an entry that turned out wrong, such as a placement that fell through. It writes a negating entry with your reason and leaves the original in place with is_reversed true.
curl -X POST "https://api.hyperstruck.com/agents/<AGENT_ID>/value-events/<ENTRY_ID>/reversal" \
-H "Authorization: Bearer <API_KEY>" \
-H "Content-Type: application/json" \
-d '{ "reason": "Customer reopened the ticket" }'reason is required, 1 to 500 characters. The reversal is dated with the entry it cancels, so a summary for any window counts both or neither. Its amounts are the negatives of the original's, and its idempotency key is reversal:<entry id>.
- Each entry can be reversed once. Retrying with the same reason returns that reversal with
200. A different reason is refused with409andalready_reversed, anddetail.idnames the reversal. - A reversal cannot be reversed (
cannot_reverse_reversal,409). - Reversing needs tracking on (
value_tracking_disabled,409), except that a retry of a reversal already made still returns it.
Read the lifetime value
GET /agents/{agent_id}/value totals the ledger. The optional window parameter is all_time (the default), last_7_days, last_30_days, last_90_days or last_365_days, counted back from now by each entry's occurred_at.
curl -H "Authorization: Bearer <API_KEY>" \
"https://api.hyperstruck.com/agents/<AGENT_ID>/value?window=last_30_days"{
"agent_id": "<AGENT_ID>",
"is_enabled": true,
"window": "last_30_days",
"measured_usd": "12.00",
"estimated_usd": "5.00",
"time_saved_minutes": "25.00",
"event_count": 2,
"first_event_at": "2026-10-29T09:05:00Z",
"last_event_at": "2026-10-29T09:20:00Z",
"by_category": [
{ "category": "support_triage", "measured_usd": "12.00", "estimated_usd": "0.00", "event_count": 1 },
{ "category": "other", "measured_usd": "0.00", "estimated_usd": "5.00", "event_count": 1 }
],
"by_unit": [
{ "unit_key": "ticket-resolved", "quantity": "1.00", "measured_usd": "12.00" }
],
"top_learnings": [
{ "learning_id": "<LEARNING_ID>", "content_preview": "When a customer reports a login loop, clear the session first.", "attributed_usd": "12.00", "event_count": 1 }
]
}| Field | Meaning |
|---|---|
measured_usd, estimated_usd | Net of reversals, reported apart and never added together. |
time_saved_minutes | Minutes across both kinds, net of reversals. |
event_count | Entries in the window, with each reversal canceling the entry it reverses rather than counting as one of its own. |
by_category | Totals per category, largest measured value first. Entries without a category group under other. A category whose entries are all reversed is left out. |
by_unit | Rate-card units, measured entries only, with the net quantity. |
top_learnings | Up to five learnings that earned the most measured value. Each entry's amount is shared equally between the learnings and claims it credits, and attributed_usd is a learning's share. content_preview is the first 160 characters of the learning, or null when it could not be read. |
The response also carries value_description and guidance, the same as the profile's; they are left out of the example. Summaries still work while tracking is off, over whatever the ledger already holds.
Errors
Refusals from the value endpoints carry detail.code and detail.message.
| Status | detail.code | When |
|---|---|---|
400 | invalid_cursor | The cursor is malformed or belongs to a different agent or filter. |
403 | none | The key lacks the scope or access to the agent, or no agent has that id. detail is Not authorized. |
404 | event_not_found | The entry does not exist on this agent. |
409 | value_tracking_disabled | Recording or reversing while tracking is off. |
409 | idempotency_conflict | The key was used for a different request. detail.id is the entry it wrote. |
409 | already_reversed | The entry has a reversal with a different reason. detail.id is the reversal. |
409 | cannot_reverse_reversal | The entry is itself a reversal. |
422 | missing_value | No figure was sent. |
422 | invalid_value | A unit sent beside amount_usd or time_saved_minutes, or quantity without a unit_key. |
422 | non_positive_value | A quantity, amount or minutes of zero or less. |
422 | value_too_large | The entry works out to more than 10,000,000 dollars or 525,600 minutes. |
422 | unknown_unit | unit_key is not on the rate card. The message lists the known keys. |
422 | baseline_required | amount_usd without a baseline. |
422 | future_occurred_at | occurred_at is more than five minutes ahead of now. |
422 | run_not_found | The run_id has no kept offers and no learning_ids were named. |
422 | attribution_not_offered | A learning or claim the run was not offered, or claim_ids without a known run. |
422 | unknown_learning | A learning_ids entry that is not one of this agent's learnings. |
503 | learning_check_unavailable | The learnings could not be confirmed. Retry shortly. |
503 | none | Access could not be checked. Retry shortly. |
A request that breaks a field rule, such as an unknown field, a reserved key prefix or a malformed evidence_url, is refused with 422 in the standard validation shape instead.
Value on a run close
A run can report its value when it closes, instead of a second call. Add a value object to the body of POST /reinforce (the MCP server's complete_run takes the same object as its value argument). It takes the fields of a ledger entry except run_id and occurred_at, which come from the run itself: idempotency_key, unit_key, quantity, amount_usd, time_saved_minutes, kind, category, baseline, note, evidence_url, learning_ids and claim_ids. The same rules apply.
The entry is written for the run's own run_id, and when idempotency_key is left out it is run:<run_id>, so a run records value once. Everything the run was offered is credited unless you name ids.
curl -X POST "https://api.hyperstruck.com/reinforce" \
-H "Authorization: Bearer <API_KEY>" \
-H "Content-Type: application/json" \
-d '{
"agent_name": "support-agent",
"episode": {
"run_id": "support-agent:SUP-2049:attempt-1",
"goal": "Triage ticket SUP-2049 and recommend the next action.",
"outcome": { "is_success": true }
},
"is_delivered": true,
"value": { "unit_key": "ticket-resolved", "category": "support_triage" }
}'{
"status": "accepted",
"run_id": "support-agent:SUP-2049:attempt-1",
"is_duplicate": false,
"value_status": "recorded",
"value_event_id": "<ENTRY_ID>",
"value_error": null,
"keys_awaiting_review": null,
"worker_payload_version": "boundary-worker-payload.v1"
}The entry it writes has source boundary, idempotency_key run:support-agent:SUP-2049:attempt-1, and recorded_by type agent.
Recording value never fails the close. The 202 response says what happened in three fields:
| Field | Meaning |
|---|---|
value_status | recorded, replayed (the key had already recorded it), tracking_disabled, rejected or failed. null when no value was sent. |
value_event_id | The entry recorded or replayed, when there is one. |
value_error | Why a value was not recorded, as one of the codes above, or agent_not_found, forbidden (the key cannot write to the agent) or invalid_value. |
A value that does not match the schema is refused with 422 like any other field. Through the MCP server's complete_run, a malformed value instead closes the run without it and returns value_status rejected with value_error invalid_value and a value_message. When value_status is failed, record the value again with POST /agents/{agent_id}/value-events.
Agents list
GET /agents takes include_value=true to add a value block to each agent: is_enabled, measured_usd, estimated_usd, time_saved_minutes, value_description, rate_card_keys and guidance, all lifetime totals. See List agents.