Distill
Turn a document, a diff, or a post-mortem into durable learnings and claims, when there is no run trace to observe.
In one line
observe is for what your agent did. distill is for what your agent read.
Most of what an agent learns comes out of finished work, through the learning loop. But plenty of durable knowledge never passes through a run at all. A post-mortem explains why last quarter's migration went wrong. An RFC settles a convention the whole team now follows. A diff shows the shape of a fix. None of that is an execution trace, and submitting it as one means inventing tool steps that never happened, which corrupts the very evidence the corpus is built on.
POST /distill is the door for that material. It takes a corpus of evidence and extracts the same kind of learnings and claims the automatic loop produces, without pretending the corpus was a run.
When to use it
| You have | Use |
|---|---|
| A finished run your agent actually performed | observe |
| Records with their own ids that change or get deleted (meeting notes, messages, email) | The Documents API |
| A document, diff, RFC, or post-mortem worth learning from | distill |
| The final learning text already written, in your own words | Store a learning |
The distill row is the one people miss. The last one is worth stating too: a learning you already have, sent here, pays for an extraction that can only rediscover what you typed. distill reads a batch once. It does not keep a record in step with its source.
Submit a corpus
curl -X POST "https://api.hyperstruck.com/distill" \
-H "Authorization: Bearer <API_KEY>" \
-H "Content-Type: application/json" \
-d '{
"agent_name": "support-agent",
"run_id": "distill:checkout-postmortem-2026-07",
"goal": "What the checkout outage taught us about retry configuration.",
"outcome": { "is_success": true },
"evidence": [
{
"id": "postmortem",
"content": "The retry budget was shared across all three downstream calls...",
"role": "contrast",
"label": "incident",
"subject": "checkout-service",
"declared_sensitivity": {
"provenance": {
"source_id": "wiki",
"source_class": "document",
"source_time": "2026-07-19T09:00:00Z",
"author": "sre-oncall"
}
}
}
]
}'Required scope: agents:write. Returns 202 Accepted once the request validates; extraction happens in the background.
Check is_duplicate on that response. run_id is your idempotency key, so a second submission under a run_id already used is accepted and then does nothing at all. It is still a 202, and without reading the flag a resend of a corrected corpus looks like it worked and produced silence.
agent_name is a name, not a UUID
distill is not scoped under /agents/{agent_id}. Its body carries agent_name, and it must be the same name you use on resolve and observe if you want everything landing in one corpus. Passing the hosted agent UUID here does not fail; it files the work under a different agent whose name happens to be a UUID. See what the name is and when it creates an agent.
Fields
Prop
Type
That is the whole vocabulary. The request model rejects any field it does not recognize, so an unlisted key is a 422 rather than something quietly ignored.
The evidence item
One item is one thing you are handing over: a single document, one message, one row. Only id and content are required, but the three optional fields below decide whether the claims inside it are usable.
Prop
Type
Declaring where an item came from
A claim is only as useful as its source. The provenance record inside declared_sensitivity is how an item says where it came from, and it is the difference between a claim the runtime can act on and one it holds at arm's length.
"declared_sensitivity": {
"provenance": {
"source_id": "salesforce",
"source_class": "crm",
"source_time": "2026-08-17T06:00:00Z",
"author": "nightly-sync"
}
}Prop
Type
Those four keys are the whole vocabulary. Anything else in the record is dropped, as is any value over 200 characters, because the record is stored verbatim and rendered into a prompt.
An item with no provenance yields no claims
Declaring provenance is what makes an evidence item a source of claims. Without it the item still feeds learning extraction, but the claims inside it are not written, and nothing errors: the job returns 202 and the corpus is simply thinner than you expect. If a corpus lands and no claims appear, check this first.
A declared source is a statement about where content came from, never a claim that it is trustworthy. Claims from a source nobody has vouched for are still used, and render with an [unverified] marker. A space steward vouches for a source once to drop that marker for everything bearing it.
Either way, a declared source's claims reach your host twice: in the recalled block a resolve returns, injected_facts_text, fenced and carrying a caveat on every line, and as labeled data in claims. Claims that arrived on a channel anyone can write to are held on arrival. If a person releases one, it reaches you as claims data alone and never enters injected_facts_text, whatever anyone vouches for.
Pre-redact your secrets
Evidence is text you chose to send. Credentials are blocked before anything is stored or indexed, but the cheapest place to remove a key from a diff is before it leaves your process.
A corpus over the limit says where to cut
A corpus is capped at 240,000 non-whitespace characters in total, across up to 200 items, and a single item may be the whole corpus: a whole meeting transcript, a long email thread, or a contract goes in as one item rather than pre-split into fragments. A corpus that large takes minutes rather than seconds, and the results arrive on the poll, so treat the job as asynchronous work rather than a request you wait on. Send more than the total and the request is refused with a 400 before any job is accepted, and the refusal tells you where to split rather than leaving you to measure. Its detail is an object instead of a message:
{
"code": "corpus_over_limit",
"total_chars": 301200,
"limit": 240000,
"fits_through_index": 37,
"next_job_starts_at": { "index": 38, "id": "email-2026-08-19-0412" },
"message": "Evidence totals 301,200 characters, over the 240,000 limit. Items 0-37 fit this job; start the next job at item 38 (email-2026-08-19-0412)."
}fits_through_index is the last item, in the order you sent them, whose running total is still within the limit. Resend items 0 to fits_through_index as this job and start the next job, with its own run_id, at next_job_starts_at. Order items so that what belongs together sits together, since the split falls on an item boundary and never inside one. The Python client and the hyper-learning skill compute the same index before the request leaves your machine, so you see one answer whichever side refuses.
These limits are the current operating point, not the destination: a larger corpus will be accepted whole, and split for you, in a later release.
A corpus without contrast is accepted, not refused
Contrast is what the learning extractor compares against, so a corpus that declares none has less for it to work with. That used to be a 400. It is not any more, because the same corpus can be full of claims, and refusing it at the door refused the claim shelf along with the learning shelf.
So the guidance now arrives after the job rather than instead of it. Poll GET /learning-runs/{run_id}/status with the run_id you submitted.
| Field | What it tells you |
|---|---|
state | queued, running, or finished. |
learning_count, claim_count | What the job wrote to each shelf. Null while the run is unfinished, which is not the same as zero. |
zero_reason | Why a count came back zero. Set only when one did. |
corpus_items_lost | Items in a pass that failed, so they were never read. Non-zero means resending is worthwhile. |
corpus_items_with_claims | Items that produced at least one claim. |
zero_reason is a closed set, so you can branch on it: weak_contrast, nothing_extracted, dedup_absorbed, no_entity_resolvable, gate_rejected, failed. The first two are the pair worth telling apart. weak_contrast means the corpus declared no contrast, so add some. nothing_extracted means it declared contrast its text does not actually carry, so the corpus itself held nothing reusable. An earlier reason can preempt weak_contrast, so read the value you get rather than assuming which one applies.
A zero-yield job still costs
The spend reservation settles as used, not released. The flag is about the work done rather than what the work yielded, and a pass that ran a model call and stored nothing has spent the money the cap exists to see. Resubmitting a contrast-free corpus in a loop consumes budget every pass. Read zero_reason, fix the corpus, then resend under a new run_id; the old one is already claimed and would be accepted as a duplicate that does nothing.
What comes back
Extracted learnings are searchable through GET /agents/{agent_id}/learnings/search once the status endpoint reports finished, alongside everything the automatic loop earned. There is no separate shelf for distilled content: a learning earned from a post-mortem sits in the same corpus as a learning earned from a run, and earns its place on the same evidence and outcome machinery. A distillation is stamped as its own kind of job, so loop-closure reporting can tell the two apart without the corpus having to.
Claims extracted from the same corpus land on the claim shelf under each evidence item's subject, or under the entity read from its prose when none was declared, and are governed exactly like any other claim, including holds, corroboration, and erasure.
Related pages
- Learning loop for the resolve and observe pairing this sits beside
- Learning for what a learning is and how it earns its place
- Claims for the claim shelf a distilled corpus also writes to
- Learnings API for searching, storing, and reinforcing by hand
- MCP server for driving the same operation as a tool from an IDE