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.
| Route | What it does |
|---|---|
POST /agents/{agent_id}/obligations | Record an obligation |
GET /agents/{agent_id}/obligations | List and search |
GET /agents/{agent_id}/obligations/{obligation_id} | Read one |
POST .../{obligation_id}/close | Close as kept or dropped |
POST .../{obligation_id}/cancel | Withdraw a mistaken record |
POST .../{obligation_id}/reschedule | Move the due |
POST .../{obligation_id}/supersede | Replace it with a successor |
GET /agents/{agent_id}/obligations/open-count | Count what is open |
GET /agents/{agent_id}/obligation-credit | Read how reliable each source has been |
GET /org/obligations | List across every agent |
GET /org/obligations/open-count | Count 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.
| Field | Required | What it holds |
|---|---|---|
statement | Yes | The commitment, 1 to 500 characters. |
owed_by | Yes | The party who owes it. See parties. |
owed_to | Yes | The party it is owed to. |
subject | No | A party the obligation is about, when that is neither side. |
due | No | When it is due. See dates. Omit it for an obligation with no date. |
not_before | No | The first instant acting on it makes sense. Must carry an offset. |
lead_days | No | Days before the due that it becomes actionable. Defaults to 3. |
expires_at | No | When it expires if nobody closes it. Must carry an offset, be after now, and not be earlier than the due. |
timezone | No | IANA zone, used as the due's zone when due.tz is omitted. |
premises | No | Ids of the claim versions this obligation rests on. An id that does not exist makes the write refused_invalid. |
due_from_claim_id | No | The claim its due was taken from. |
provenance | No | A 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.
outcome | Meaning |
|---|---|
written | A new obligation was recorded. id is its id. |
deduped | An 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. |
suppressed | An 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_capacity | The agent holds too many open obligations. See limits. |
refused_invalid | The 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.
| Field | What it holds |
|---|---|
role | A label you choose, up to 40 characters: account_manager, agent, principal. |
entity | A person's or organization's name, up to 500 characters. It is matched to an existing entity, or a new one is created. |
entity_id | The 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.precision | Overdue when |
|---|---|
date | The 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"| Parameter | Default | What it does |
|---|---|---|
status | all | One of open, kept, dropped, expired, superseded, cancelled. |
entity_id | none | Obligations naming this entity as either party or as the subject. |
due_before | none | Obligations whose due_at is before this instant. For a date due, due_at is the start of the following day. |
q | none | Case-insensitive match anywhere in the statement, up to 200 characters. % and _ are matched literally. |
provenance_class | all | user_directed, source_observed, agent_committed or rule_implied. |
needs_review | false | Only open obligations waiting on a person. Each item then carries review_reasons. |
review_reason | all | With needs_review=true, one of untrusted_provenance, premise_retracted, later_standing, neglected. |
limit | 50 | Page size, 1 to 500. |
after | none | The previous page's next_cursor, passed back unchanged. |
timezone | the agent's zone, else UTC | IANA 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:
| Field | What it holds |
|---|---|
kind | commitment, or meeting for one owed jointly by its attendees. |
owed_by_name, owed_to_name, subject_name | The entity's current name, looked up on each read. null for a party that is only a role. |
participants | Everyone on it when a side was several people, each with a role of owner, recipient or attendee. Empty when each side is one party. |
status | open, kept, dropped, expired, superseded or cancelled. |
kept_basis, dropped_reason, expired_reason | Why it closed. The one matching status is set and the others are null. |
superseded_by | The successor's id, on a superseded obligation. |
version | Rises by one on every close, cancel, reschedule or supersede. Send it as expected_version to guard a write. |
expires_at | When it expires if nobody closes it. |
provenance_class | On whose word it is held. |
provenance | A free-form object recorded with it. |
surfaced_count, delivered_count | How often it was selected for the agent, and how often it was actually shown. |
later_standing | What a later note said about it: already_done, withdrawn or superseded, with the phrase. The obligation stays open. |
note | The 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.state | Meaning |
|---|---|
shown | The statement needs its note to make sense, and note.passages holds the passages. |
not_needed | The statement stands alone. |
erased | Every note it came from was erased. |
unavailable | The 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" }'| Field | Required | What it holds |
|---|---|---|
outcome | Yes | kept or dropped. |
kept_basis | With kept | Who said it was done: reported, evidenced or declared. |
dropped_reason | With dropped | not_an_obligation, no_longer_applies, wont_do or duplicate. |
note | No | Up to 500 characters. |
expected_version | No | The 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 field | What it holds |
|---|---|
injected_obligations_text | The block to place in front of your model, or null when nothing is due. |
offered_obligation_ids | Every obligation selected for this turn. |
delivered_obligation_ids | The subset the block actually carried. Empty when the block carried nothing. |
| Request field | Default | What it does |
|---|---|---|
max_obligations | 5 | How many the block may carry, 0 to 20. 0 turns the block off for this call. |
obligation_horizon_days | 0 for agent_loop, 7 for explicit_recall | How far ahead the block looks, 0 to 365. An obligation already inside its window is returned whatever the horizon. |
timezone | the agent's zone, else UTC | IANA zone the block's dates are rendered in. It also decides when a date due becomes overdue. |
as_of | now | The 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:
disposition | Meaning |
|---|---|
applied | It moved to the state you asked for. |
already_closed | It was already in exactly that state. Nothing is wrong. |
conflicting_close | A different closed state holds, and your report was dropped. |
not_offered | This run was never offered that id. |
not_found | The id was offered and the obligation no longer exists. |
refused | The 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:
| Field | What it holds |
|---|---|
live_open_count | Open obligations counted now, across the spaces the caller can read. |
metered_open_count | The figure recorded for usage_date by the nightly count, or null when it has not run for that day. null is not zero. |
is_reconciled | true when the two agree, false when they differ, null when there is nothing to compare. |
is_space_filtered | true 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.
| Field | What it holds |
|---|---|
source_id | The source. |
applied | Weight of outcomes that said the obligation was real. Any kept counts. |
misled | Weight of outcomes that said it was not: a drop for not_an_obligation, or an expiry for neglect. |
score | A 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_update | When 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:
| Field | What it holds |
|---|---|
is_space_filtered | true when the caller reads only some spaces. A short page then does not mean the end of the list. |
withheld_count | How 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
| Limit | Value |
|---|---|
| Open obligations per agent | 500. A direct write still lands above that, up to 1,000 open, and is then refused with refused_capacity. |
| Statement | 500 characters |
| Role | 40 characters |
| Close note | 500 characters |
Search q | 200 characters |
| Page size | 500 |
| Outcomes on one reinforce | 50 |
| Lifetime | 14 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
| Status | When |
|---|---|
401 | The key is missing, malformed, revoked or expired. |
403 | The 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. |
404 | The agent or the obligation does not exist for this caller. |
409 | The 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. |
422 | The body or a parameter is invalid. |
504 | A 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'" }