Hyperstruck
API reference

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.

EndpointScopeWhat it does
GET /agents/{agent_id}/value-profileagents:readRead the profile
PUT /agents/{agent_id}/value-profileagents:writeReplace the profile
POST /agents/{agent_id}/value-eventsagents:writeRecord an outcome
GET /agents/{agent_id}/value-eventsagents:readList the ledger
POST /agents/{agent_id}/value-events/{event_id}/reversalagents:writeReverse an entry
GET /agents/{agent_id}/valueagents:readRead 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."
      }
    ]
  }'
FieldTypeDefaultMeaning
is_enabledboolean, requirednoneTurns tracking on. While off, new entries are refused with 409 and history stays readable.
value_descriptionstring, up to 2,000 charactersnullWhat counts as value, in plain words. A blank string is stored as null.
hourly_rate_usddecimal above 0, up to 10,000, two decimal placesnullTurns minutes into dollars. null uses the default of 100.
is_auto_estimate_enabledbooleantrueLets the platform write an estimate after a successful run that applied learnings.
minutes_per_applied_learningdecimal above 0, up to 1,440, two decimal placesnullMinutes the estimate credits for each applied learning. null uses the default of 10.
rate_cardlist of up to 50 units[]Named, priced outcomes. Keys must be unique.

Each rate-card unit:

FieldTypeMeaning
keystring, requiredWhat callers send as unit_key. Lowercase letters, digits, _, . and -, starting with a letter or digit, up to 64 characters.
labelstring, requiredA display name, 1 to 120 characters.
amount_usddecimal above 0, up to 10,000,000Dollars one unit is worth. Leave out for a unit that only saves time.
time_saved_minutesdecimal above 0, up to 525,600Minutes one unit saves. When amount_usd is left out, dollars are these minutes at the hourly rate.
descriptionstring, up to 500 charactersWhen 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."
  }'
FieldTypeMeaning
idempotency_keystring, required, 1 to 200 charactersNames the outcome. Unique per agent. Keys starting reversal: or auto: are reserved and refused with 422.
unit_keystring, up to 64 charactersA rate-card unit. Send it alone, or with quantity.
quantitydecimal up to 10,000, two decimal placesHow many units. Defaults to 1. Needs unit_key.
amount_usddecimal up to 10,000,000, two decimal placesA dollar figure someone stated. Needs baseline.
time_saved_minutesdecimal up to 525,600, two decimal placesMinutes saved. Alone, dollars are these minutes at the hourly rate. Beside amount_usd it is recorded as extra context.
baselinestring, up to 500 charactersWhat the outcome would have cost without the agent, in words a reviewer can check. Required with amount_usd.
kindmeasured or estimatedLeave it out for measured. Send estimated only to mark your own figure as a guess.
categorystring, up to 100 charactersA label for grouping in the summary. Entries without one group under other.
notestring, up to 1,000 charactersFree text.
evidence_urlstring, up to 2,000 charactersA link to the evidence. Must start with http:// or https://.
run_idstring, up to 512 charactersThe run_id of the learning run that produced the outcome. Credits what that run was offered.
learning_idslist of up to 50 stringsLearnings to credit.
claim_idslist of up to 50 stringsClaims to credit. Needs a known run_id.
occurred_attimestampWhen 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 sendbasis on the entryDollarsMinutes
unit_key where the unit has amount_usdrate_cardThe unit's amount times quantityThe unit's minutes times quantity
unit_key where the unit has only minutesrate_cardThe minutes at the hourly rateThe unit's minutes times quantity
amount_usd and baselinestatedAs statedtime_saved_minutes if sent, else 0
time_saved_minutes alonetime_derivedThe minutes at the hourly rateAs 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 sendCredited
run_id aloneEvery learning and claim that run was offered, up to 50 of each
run_id with learning_ids or claim_idsJust those, each of which the run must have been offered. Anything else is refused with attribution_not_offered.
learning_ids aloneThose learnings, each of which must belong to this agent. Anything else is refused with unknown_learning.
NeitherNothing

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"
}
FieldMeaning
kind, basisHow 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_usdThe price or rate copied when the entry was written.
is_reversedTrue when a later reversal cancels this entry.
reverses_event_id, reversal_reasonSet on a reversal entry only.
sourceWhich 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_byWho 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 parameterDefaultMeaning
kindnonemeasured or estimated, to list only one kind.
limit25Page size, 1 to 100.
cursornoneThe 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 with 409 and already_reversed, and detail.id names 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 }
  ]
}
FieldMeaning
measured_usd, estimated_usdNet of reversals, reported apart and never added together.
time_saved_minutesMinutes across both kinds, net of reversals.
event_countEntries in the window, with each reversal canceling the entry it reverses rather than counting as one of its own.
by_categoryTotals per category, largest measured value first. Entries without a category group under other. A category whose entries are all reversed is left out.
by_unitRate-card units, measured entries only, with the net quantity.
top_learningsUp 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.

Statusdetail.codeWhen
400invalid_cursorThe cursor is malformed or belongs to a different agent or filter.
403noneThe key lacks the scope or access to the agent, or no agent has that id. detail is Not authorized.
404event_not_foundThe entry does not exist on this agent.
409value_tracking_disabledRecording or reversing while tracking is off.
409idempotency_conflictThe key was used for a different request. detail.id is the entry it wrote.
409already_reversedThe entry has a reversal with a different reason. detail.id is the reversal.
409cannot_reverse_reversalThe entry is itself a reversal.
422missing_valueNo figure was sent.
422invalid_valueA unit sent beside amount_usd or time_saved_minutes, or quantity without a unit_key.
422non_positive_valueA quantity, amount or minutes of zero or less.
422value_too_largeThe entry works out to more than 10,000,000 dollars or 525,600 minutes.
422unknown_unitunit_key is not on the rate card. The message lists the known keys.
422baseline_requiredamount_usd without a baseline.
422future_occurred_atoccurred_at is more than five minutes ahead of now.
422run_not_foundThe run_id has no kept offers and no learning_ids were named.
422attribution_not_offeredA learning or claim the run was not offered, or claim_ids without a known run.
422unknown_learningA learning_ids entry that is not one of this agent's learnings.
503learning_check_unavailableThe learnings could not be confirmed. Retry shortly.
503noneAccess 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:

FieldMeaning
value_statusrecorded, replayed (the key had already recorded it), tracking_disabled, rejected or failed. null when no value was sent.
value_event_idThe entry recorded or replayed, when there is one.
value_errorWhy 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.