REST integration
Wire the learning loop into an agent you already run, in any language, with no SDK and no middleware.
In one line
Four endpoints, one correlation key you invent, and no dependency on our SDK: resolve before the work, then reinforce, observe, or decline after it.
The LangGraph middleware hides this loop because it can see your run start and end. If you are on another framework, another language, or your own orchestration, you drive the same loop directly. Nothing about the corpus is different: a learning earned through REST competes with one earned through the middleware on identical evidence.
The shape of it
your agent's turn
|
resolve ------->| what prior runs learned, before the work
|
(the model does the work)
|
reinforce -----> | what actually happened, so the offer is credited
or observe | a full trace, when you have one
or decline | nothing worth learning, said out loudEvery call carries two things: agent_name, the human-readable name of the corpus you are reading and writing, and run_id, a correlation key you invent. Reusing one run_id across the pair is the entire attribution mechanism.
agent_name is a name you choose
It is unique within your tenant and it is not the hosted agent UUID used in /agents/{agent_id} paths. If no agent by that name exists, one is created on first use, which needs agents:write: a read-only key meeting an unknown name gets a 403 rather than a new agent. Send the same name every time or your corpus splits in two.
run_id is yours, and it is not a hosted run
GET /runs/{run_id} will not find it. It is an idempotency and attribution key you own. It must also avoid the prefix reserved for corpus distillation, which that page names.
Before the work: resolve
curl -X POST "https://api.hyperstruck.com/resolve" \
-H "Authorization: Bearer <API_KEY>" \
-H "Content-Type: application/json" \
-d '{
"agent_name": "support-agent",
"run_id": "support-agent:ticket-2048:attempt-1",
"goal": "Triage ticket SUP-2048 and recommend the next action.",
"available_tools": [
{ "name": "ticket_lookup", "description": "Retrieve a support ticket by identifier." }
],
"source_framework": "custom-api-client"
}'Required scope: agents:read, or agents:write if the name is new. The response carries four fields, and both shelves come back in the same call.
| Field | What to do with it |
|---|---|
injected_text | The learnings block. Place it where your model will actually read it. |
offered_learning_ids | What was offered, so the write-back can credit it. Keep them for the turn. |
injected_facts_text | The claims block: specific facts about the things this goal names. Rendered separately so you can place or fence it differently. |
offered_claim_ids | The same, for facts. |
Pass available_tools if you can. A lesson about a tool only reaches runs that can actually use that tool, so omitting them silently narrows what you get back. source_framework attributes the per-host funnel below, so give it a stable value for your integration.
After the work: pick one of three
This is the half that hosts get wrong, and the failure is always the same shape: the run resolves and then nothing arrives. An unclosed run is indistinguishable from a host that stopped writing back, and only you know which it was.
| You have | Send | Why |
|---|---|---|
| A real execution trace | observe | Steps, tools, outcome. New learnings are extracted from it and the offer is credited. |
| An outcome but no trace worth sending | reinforce | Credits or corrects the learnings resolve offered, without extracting new ones. |
| Nothing worth learning from | decline | Closes the run honestly. Nothing is added to the corpus. |
observe
The episode fields are documented in full on the learning loop page. Required scope: agents:write. Returns 202.
reinforce
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:ticket-2048:attempt-1",
"goal": "Triage ticket SUP-2048 and recommend the next action.",
"outcome": { "is_success": true }
},
"is_delivered": true
}'Required scope: agents:write. Returns 202; processing is asynchronous.
Beyond agent_name, the episode carrying your run_id, goal and outcome, and an optional org_id, three fields report what happened to the recall itself, and a fourth governs organization sharing. The three are what turn a write-back into evidence rather than an assertion.
Prop
Type
If you can only send one of these three, send is_delivered. It is what separates a broken integration from a quiet one.
decline
curl -X POST "https://api.hyperstruck.com/decline" \
-H "Authorization: Bearer <API_KEY>" \
-H "Content-Type: application/json" \
-d '{
"agent_name": "support-agent",
"run_id": "support-agent:ticket-2048:attempt-1",
"reason": "no_tool_calls",
"is_delivered": true
}'Required scope: agents:write. reason is a closed set that grows as new cases are named, currently including no_tool_calls, below_material_threshold, empty_offer, and unevidenced_outcome; take the current members from the schema, since an unrecognised value is rejected rather than stored. A run_id this agent never resolved is accepted and ignored, so you can call it defensively.
Set is_delivered honestly here too. A turn can receive the injection and still have nothing to teach, and the flag is what decides whether that resolve is billed or released.
Check your own loop: funnel
GET /funnel reports the closure funnel per producing host: resolved, offered, observed, reinforced, plus half_open, half_open_rate, loop_closure_rate, in_flight, parked, parked_stalled, and avg_seconds_to_close.
curl -H "Authorization: Bearer <API_KEY>" \
"https://api.hyperstruck.com/funnel"Required scope: agents:read. Scoped to your tenant, grouped by the source_framework your calls declare, which is the reason to set it.
Two parameters shape the answer. window_hours defaults to 24, and in practice 24 is also the ceiling: the window is narrowed to the retention interval, which resolves to one day in shipped deployments, so a larger request is served at 24 and the response reports the window actually used. grace_minutes defaults to 120 and is how long an asynchronous write-back has before its run counts as half-open, so a run still legitimately in flight is reported as in-flight rather than as a failure. A window shorter than twice the grace period is refused with a 422 rather than answered, because it holds no run old enough to have closed.
A run that resolved and never wrote back shows here as a non-closure, not as a false closure. That is the number to watch after you ship, and it is the only place the failure is visible, because a host that stops writing back reports nothing by definition.
Read parked_stalled beside it before concluding what is wrong. It counts inside half_open and rises when a write-back container dies without running at all, which is a different fault from a live host whose write-back path is dropping turns.
Checklist
- One
agent_name, for the life of the integration. - One
run_idper turn, reused across the pair. - A stable
source_framework. - Exactly one write-back per resolve.
is_delivered, always.half_open_rateafter launch.
Related pages
- Learning loop for the concept and the LangGraph path
- Framework integrations for LangGraph, the OpenAI Agents SDK, and CrewAI
- Distill for documents and post-mortems that were never a run
- Learnings API for storing, searching, and reinforcing specific lessons by hand
- Claims for the fact shelf that comes back through the same resolve