Hyperstruck
API

Obligations API

Record what is owed, list and search it, close it as kept or dropped, move its due date, and read the review queue across every agent you run.

In short

Record an obligation with one call, read it back with its due rendered in your time zone, and close it when it is kept or dropped. For what an obligation is and how notes produce them, read Obligations first.

Access control

Reads need the agents:read scope and writes need agents:write. The agent-scoped routes also require that the caller can read, or write to, the agent in the path. The two /org/obligations routes are tenant-wide and return only rows in spaces the caller can read.

RouteWhat it does
POST /agents/{agent_id}/obligationsRecord an obligation
GET /agents/{agent_id}/obligationsList and search
GET /agents/{agent_id}/obligations/{obligation_id}Read one
POST .../{obligation_id}/closeClose as kept or dropped
POST .../{obligation_id}/cancelWithdraw a mistaken record
POST .../{obligation_id}/rescheduleMove the due
POST .../{obligation_id}/supersedeReplace it with a successor
GET /agents/{agent_id}/obligations/open-countCount what is open
GET /agents/{agent_id}/obligation-creditRead how reliable each source has been
GET /org/obligationsList across every agent
GET /org/obligations/open-countCount across the tenant

Record an obligation

POST /agents/{agent_id}/obligations writes one obligation exactly as you state it.

curl -X POST "https://api.hyperstruck.com/agents/11111111-1111-4111-8111-111111111111/obligations" \
  -H "Authorization: Bearer <API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{
    "statement": "Send the signed carrier agreement to Harlow Freight",
    "owed_by": { "role": "account_manager" },
    "owed_to": { "entity": "Harlow Freight" },
    "due": { "at": "2026-10-09T00:00:00+11:00", "tz": "Australia/Sydney", "precision": "date" }
  }'
{
  "id": "66666666-6666-4666-8666-666666666666",
  "outcome": "written",
  "evicted_id": null,
  "reason": null
}

The response is always 202. Read outcome to learn what happened, because a refusal is reported there and not as an error status.

FieldRequiredWhat it holds
statementYesThe commitment, 1 to 500 characters.
owed_byYesThe party who owes it. See parties.
owed_toYesThe party it is owed to.
subjectNoA party the obligation is about, when that is neither side.
dueNoWhen it is due. See dates. Omit it for an obligation with no date.
not_beforeNoThe first instant acting on it makes sense. Must carry an offset.
lead_daysNoDays before the due that it becomes actionable. Defaults to 3.
expires_atNoWhen it expires if nobody closes it. Must carry an offset, be after now, and not be earlier than the due.
timezoneNoIANA zone, used as the due's zone when due.tz is omitted.
premisesNoIds of the claim versions this obligation rests on. An id that does not exist makes the write refused_invalid.
due_from_claim_idNoThe claim its due was taken from.
provenanceNoA free-form object you want kept with it, such as a ticket reference.

An unknown field is refused with a 422 that names it. A misspelled statment never produces a row missing its statement.

outcomeMeaning
writtenA new obligation was recorded. id is its id.
dedupedAn open obligation owed to the same party, with the same action and the same due day, already exists. id is the existing one, and nothing new was written.
suppressedAn identical obligation was closed as dropped in the last 30 days, so it is not recorded again. id is the dropped one, and reason says so.
refused_capacityThe agent holds too many open obligations. See limits.
refused_invalidThe shelf would not take it. reason says why.

One further value, evicted_then_written, is in the contract and never returned by this route. It applies to obligations read from notes.

Parties

A party is a role, a name, or both.

FieldWhat it holds
roleA label you choose, up to 40 characters: account_manager, agent, principal.
entityA person's or organization's name, up to 500 characters. It is matched to an existing entity, or a new one is created.
entity_idThe id of an entity you already hold, in place of a name.

Dates

due.at must carry an offset. A value such as 2026-10-09T00:00:00 is refused with a 422, never assumed to be UTC.

due.precisionOverdue when
dateThe day ends in the due's zone.
datetime (default)The exact instant passes.

due.tz is the zone the due was stated in. Omitted, it falls back to the request's timezone, then the agent's own zone, then UTC. The obligation records which with due_tz_basis, and a value of defaulted means nothing was stated and the day may be wrong.

Render due_local, never due_at

For a date due, due_at is the first instant the obligation is overdue: the start of the day after the one you meant. A due of 9 October in Sydney comes back as "due_at": "2026-10-09T13:00:00Z" and "due_local": "2026-10-09". due_local is the value to show a person.

List obligations

GET /agents/{agent_id}/obligations returns an agent's obligations, open and closed.

curl -H "Authorization: Bearer <API_KEY>" \
  "https://api.hyperstruck.com/agents/11111111-1111-4111-8111-111111111111/obligations?status=open&limit=25"
ParameterDefaultWhat it does
statusallOne of open, kept, dropped, expired, superseded, cancelled.
entity_idnoneObligations naming this entity as either party or as the subject.
due_beforenoneObligations whose due_at is before this instant. For a date due, due_at is the start of the following day.
qnoneCase-insensitive match anywhere in the statement, up to 200 characters. % and _ are matched literally.
provenance_classalluser_directed, source_observed, agent_committed or rule_implied.
needs_reviewfalseOnly open obligations waiting on a person. Each item then carries review_reasons.
review_reasonallWith needs_review=true, one of untrusted_provenance, premise_retracted, later_standing, neglected.
limit50Page size, 1 to 500.
afternoneThe previous page's next_cursor, passed back unchanged.
timezonethe agent's zone, else UTCIANA zone due_local is rendered in.

Every filter combines with every other. The response is { "items": [...], "next_cursor": "..." }, and next_cursor is null on the last page.

Page by the cursor, not by the page length

Under needs_review=true a page can come back short, or empty, while next_cursor is still set. Keep going until next_cursor is null. A malformed after is refused with a 422.

review_reasons is filled only when you ask with needs_review=true. On any other read it is empty, which says nothing either way about the obligation.

Read one obligation

GET /agents/{agent_id}/obligations/{obligation_id} returns one obligation. Pass timezone to choose the zone due_local is rendered in.

{
  "id": "66666666-6666-4666-8666-666666666666",
  "agent_id": "11111111-1111-4111-8111-111111111111",
  "kind": "commitment",
  "statement": "Send the signed carrier agreement to Harlow Freight",
  "owed_by_role": "account_manager",
  "owed_by_entity_id": null,
  "owed_by_name": null,
  "owed_to_role": null,
  "owed_to_entity_id": "77777777-7777-4777-8777-777777777777",
  "owed_to_name": "Harlow Freight",
  "not_before": "2026-10-06T13:00:00Z",
  "due_at": "2026-10-09T13:00:00Z",
  "due_local": "2026-10-09",
  "due_tz": "Australia/Sydney",
  "due_tz_basis": "caller",
  "due_precision": "date",
  "lead_days": 3,
  "provenance_class": "user_directed",
  "status": "open",
  "kept_basis": null,
  "dropped_reason": null,
  "expired_reason": null,
  "superseded_by": null,
  "expires_at": "2026-10-23T13:00:00Z",
  "committed_at": "2026-10-01T01:47:16.322767Z",
  "closed_at": null,
  "version": 1,
  "note": null,
  "participants": [],
  "review_reasons": []
}

The example is shortened. The fields that matter most:

FieldWhat it holds
kindcommitment, or meeting for one owed jointly by its attendees.
owed_by_name, owed_to_name, subject_nameThe entity's current name, looked up on each read. null for a party that is only a role.
participantsEveryone on it when a side was several people, each with a role of owner, recipient or attendee. Empty when each side is one party.
statusopen, kept, dropped, expired, superseded or cancelled.
kept_basis, dropped_reason, expired_reasonWhy it closed. The one matching status is set and the others are null.
superseded_byThe successor's id, on a superseded obligation.
versionRises by one on every close, cancel, reschedule or supersede. Send it as expected_version to guard a write.
expires_atWhen it expires if nobody closes it.
provenance_classOn whose word it is held.
provenanceA free-form object recorded with it.
surfaced_count, delivered_countHow often it was selected for the agent, and how often it was actually shown.
later_standingWhat a later note said about it: already_done, withdrawn or superseded, with the phrase. The obligation stays open.
noteThe passage it was read from. Only on this route, and only for an obligation read from a stored note.

note.state says what you are looking at:

note.stateMeaning
shownThe statement needs its note to make sense, and note.passages holds the passages.
not_neededThe statement stands alone.
erasedEvery note it came from was erased.
unavailableThe note could not be read just now.

Close an obligation

POST /agents/{agent_id}/obligations/{obligation_id}/close ends an obligation as kept or dropped. It returns 200 with the obligation.

curl -X POST "https://api.hyperstruck.com/agents/11111111-1111-4111-8111-111111111111/obligations/66666666-6666-4666-8666-666666666666/close" \
  -H "Authorization: Bearer <API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{ "outcome": "kept", "kept_basis": "reported", "note": "Sent by email" }'
FieldRequiredWhat it holds
outcomeYeskept or dropped.
kept_basisWith keptWho said it was done: reported, evidenced or declared.
dropped_reasonWith droppednot_an_obligation, no_longer_applies, wont_do or duplicate.
noteNoUp to 500 characters.
expected_versionNoThe version you last read. A stale one is refused.

Only not_an_obligation counts against the source the obligation came from. The other three reasons judge the commitment, not the source.

Sending the same close twice is safe: the second returns 200 with the obligation and changes nothing. A close that contradicts the state already held, or a stale expected_version, is a 409 that tells you the current version:

{ "detail": { "message": "obligation is at version 2, not 1", "current_version": 2 } }

Cancel a record

POST .../{obligation_id}/cancel withdraws a record that should never have existed, such as a test write or a bad import. It takes an optional expected_version, and an empty body {} is valid. A note is accepted and not kept.

Cancel makes no judgment about the commitment. It does not count against any source, and the same obligation can be recorded again straight away, where a drop blocks an identical write for 30 days. When the source was wrong, close with not_an_obligation instead.

Cancelling an obligation that is already cancelled returns 200 and changes nothing. Cancelling one that closed any other way is a 409.

Move the due

POST .../{obligation_id}/reschedule changes the due and keeps the obligation's id. The body is { "due": { ... } } with the same due object as a write, plus an optional expected_version. The window and the expiry move with it.

If another open obligation already sits on the new day for the same party and action, the request is a 409. Close one of the two to resolve it.

Replace an obligation

POST .../{obligation_id}/supersede closes an obligation as superseded and writes its successor, linked, in one step.

{
  "successor": {
    "statement": "Send the signed carrier agreement and the rate card to Harlow Freight",
    "owed_by": { "role": "account_manager" },
    "owed_to": { "entity": "Harlow Freight" },
    "due": { "at": "2026-10-16T00:00:00+11:00", "tz": "Australia/Sydney", "precision": "date" }
  }
}

successor takes the same fields as a write. The 200 body is the successor, which is the open obligation you now care about. The original comes back from a read with "status": "superseded" and superseded_by set. Nothing changes unless the successor lands: one that collides with another open obligation, or repeats one dropped in the last 30 days, is a 409, and the original stays open.

Close obligations from the loop

An agent that acted on what resolve offered does not need one call per obligation. Two fields on the learning loop carry it.

Resolve returns the block and the ids:

Response fieldWhat it holds
injected_obligations_textThe block to place in front of your model, or null when nothing is due.
offered_obligation_idsEvery obligation selected for this turn.
delivered_obligation_idsThe subset the block actually carried. Empty when the block carried nothing.
Request fieldDefaultWhat it does
max_obligations5How many the block may carry, 0 to 20. 0 turns the block off for this call.
obligation_horizon_days0 for agent_loop, 7 for explicit_recallHow far ahead the block looks, 0 to 365. An obligation already inside its window is returned whatever the horizon.
timezonethe agent's zone, else UTCIANA zone the block's dates are rendered in. It also decides when a date due becomes overdue.
as_ofnowThe moment the block is computed for. Must carry an offset, and may be at most 24 hours ahead.

Reinforce closes what the turn resolved. Send up to 50 entries in obligation_outcomes:

{
  "obligation_outcomes": [
    { "id": "66666666-6666-4666-8666-666666666666", "outcome": "kept", "kept_basis": "reported" }
  ]
}

Each entry takes id, outcome, kept_basis or dropped_reason, and an optional note, with the same rules as close. The response answers each one in obligation_closures, in the order you sent them:

dispositionMeaning
appliedIt moved to the state you asked for.
already_closedIt was already in exactly that state. Nothing is wrong.
conflicting_closeA different closed state holds, and your report was dropped.
not_offeredThis run was never offered that id.
not_foundThe id was offered and the obligation no longer exists.
refusedThe shelf rejected the write. Do not resend.

One stale id never costs the run its reinforcement: it is answered not_offered and the rest proceed.

Send typed obligations with a note

A Distill evidence item can carry obligations you already know, in an obligations array with the same fields as a write. They are recorded as you state them, with no model involved, up to 8 per item and 50 per request. A relative due such as "next Friday" is refused, so resolve it to a date before sending.

Count what is open

GET /agents/{agent_id}/obligations/open-count returns { "open_count": 2 }: the open, unexpired obligations this agent holds now.

GET /org/obligations/open-count returns the same count across the tenant beside the metered one:

FieldWhat it holds
live_open_countOpen obligations counted now, across the spaces the caller can read.
metered_open_countThe figure recorded for usage_date by the nightly count, or null when it has not run for that day. null is not zero.
is_reconciledtrue when the two agree, false when they differ, null when there is nothing to compare.
is_space_filteredtrue when the caller reads only some of the tenant's spaces. The metered figure is then withheld.

Pass usage_date for a day other than today, in UTC.

Read source reliability

GET /agents/{agent_id}/obligation-credit reports, for each source obligations were read from, how often they turned out to be real. The lowest score comes first.

FieldWhat it holds
source_idThe source.
appliedWeight of outcomes that said the obligation was real. Any kept counts.
misledWeight of outcomes that said it was not: a drop for not_an_obligation, or an expiry for neglect.
scoreA cautious estimate from the two, read against the agent's other sources so that a source with three outcomes is not judged on three.
last_updateWhen it last moved.

The score is a readout. Reading obligations from a source is not gated on it. Pass limit, 1 to 500, default 200.

List across every agent

GET /org/obligations takes the same filters as the agent-scoped list and returns obligations from every agent, each carrying its own agent_id. With needs_review=true it is the review queue for the whole tenant.

Two things differ. timezone defaults to UTC, because a page spans agents. And the response says when your access narrowed it:

FieldWhat it holds
is_space_filteredtrue when the caller reads only some spaces. A short page then does not mean the end of the list.
withheld_countHow many rows of this page those spaces hid. An estimate for the page, not a tenant total.

A search this wide can time out. It then returns 504 with a message asking you to narrow it by status, a shorter q, or due_before.

Limits

LimitValue
Open obligations per agent500. A direct write still lands above that, up to 1,000 open, and is then refused with refused_capacity.
Statement500 characters
Role40 characters
Close note500 characters
Search q200 characters
Page size500
Outcomes on one reinforce50
Lifetime14 days past the due, or past recording when the due had already passed. 90 days with no due. Never more than 365 days. An expires_at you send replaces the first two.

Errors

StatusWhen
401The key is missing, malformed, revoked or expired.
403The credential lacks a permission or scope the route needs, the subscription is not active, or a plan or spend limit was reached. The detail says which.
404The agent or the obligation does not exist for this caller.
409The obligation is not in the state the request assumed. detail.current_version is the version to re-read, or null when the conflict is a collision with another obligation.
422The body or a parameter is invalid.
504A tenant-wide read took too long. Narrow it and retry.

A 422 comes in two shapes. A field that fails validation returns a list naming it:

{ "detail": [{ "type": "extra_forbidden", "loc": ["body", "statment"], "msg": "Extra inputs are not permitted" }] }

A value the shelf itself refuses, such as an unknown time zone or a malformed cursor, returns a string:

{ "detail": "unknown timezone 'Mars/Olympus'" }