Connect your harness
Connect a harness in three steps:
- Send OpenAI- or Anthropic-compatible model requests to reef instead of directly to the provider.
- Keep the receipt returned with each response and post feedback against it.
- If the scenario evolves harness files, pull the published files back into the harness.
This page documents that HTTP contract. The Python examples use reef_client
to make the same requests.
Headers#
| header | required on | meaning |
|---|---|---|
x-reef-scenario | inference, report, every /reef/harness* read | which scenario the record belongs to; scenarios never share records |
x-reef-recipe | the request that first creates a scenario | the recipe the scenario binds to, for its lifetime |
x-reef-artifact-version | optional, creation only | the base artifact the scenario forks from |
Authorization: Bearer <token> | every route except /healthz | when the deployment configures a token |
The route decides the request type, and no header sets it.
/v1/chat/completions and /v1/messages are inference, and /reef/report is
a report. A missing or empty x-reef-scenario on any of them is HTTP 400. The
scenario-management and status routes (/reef/scenarios, /reef/status) take
no scenario header, and /healthz takes no header at all.
Recipe and base-artifact bindings are immutable. Repeating the bound value is fine, and so is omitting it, because reef reads the stored binding, including after a restart. A different value is HTTP 409 and never rebinds.
Create the scenario#
The first inference or report creates it, provided x-reef-recipe names a
recipe the deployment can resolve. There is no service-wide default for that
header, so creation without it is HTTP 400.
curl -sD - localhost:8900/v1/chat/completions \
-H "x-reef-scenario: code-repair" -H "x-reef-recipe: sao" \
-H "content-type: application/json" \
-d '{"model": "m", "messages": [{"role": "user", "content": "fix it"}]}'
Deployments that set reef.allow_implicit_scenario_creation: false refuse
that path with HTTP 404, and the scenario has to be declared first:
| route | body | answer |
|---|---|---|
POST /reef/scenarios | {"name", "recipe", "artifact_version"?} | {scenario, recipe, artifact_version}, 201 when created, 200 when it already existed |
GET /reef/scenarios | none | every known scenario, with its recipe and current artifact version once loaded |
GET /reef/scenarios/{scenario}/contract | none | {scenario, recipe, processor, required_request_types} |
POST /reef/scenarios creates the scenario regardless of that flag. Read the
contract once at harness startup, because required_request_types says
whether the bound recipe needs reports at all, and finding that out at boot is
cheaper than finding it out after a run.
Inference#
POST /v1/chat/completions or POST /v1/messages. The body is the
provider-native request, forwarded as sent. Artifact routing travels in
upstream headers rather than in the JSON, and reef adds no sampling parameters
of its own. The single exception is return_meta_info: true, added when the
scenario serves live weights so the engine echoes which weights answered. The
response is the provider-native response, minus the private training block
described below.
The harness needs one response header, x-reef-agent-record-id: 3f0c…. That
value is the receipt, and it is the stored record's id. Quote it in the
references of every report that grades this call. Streamed responses carry
it too, alongside the upstream's own headers.
Reef freezes the serving artifact before dispatch and stores it with the exchange, so a record always names the artifact that produced it. When a weight update lands mid-flight, reef retries the whole request against the new head, and nothing is stored for the abandoned attempt. Only two of those failures reach the caller: an engine that reports no serving version at all is HTTP 409, and a request that keeps losing the race until the retry deadline is HTTP 503.
Streaming works. Set "stream": true and read the SSE relay. Reef reassembles
the stream and records it after the last chunk, so a streamed turn's record
appears at end of stream. The serving-version check is skipped for those
records, because the engine cannot echo its version and stream at the same
time. reef_client buffers JSON, so stream with any SSE-capable client, or
with the proxy below.
from reef_client import ReefClient
client = ReefClient("http://localhost:8900", token="secret")
body = {"model": "your-model-id", "messages": [{"role": "user", "content": "fix it"}]}
response, receipt = client.inference_with_record(
"code-repair", "/v1/chat/completions", body, recipe="sao"
)
Report#
POST /reef/report carries the feedback. Four fields survive normalization,
and everything else in the body is dropped before the record is stored, so put
harness-specific fields under metadata or feedback:
| field | type | notes |
|---|---|---|
score | number | optional; a bool is not a number and is rejected |
feedback | string or object | opaque to reef's core: a rubric, judge output, plain text |
references | list of strings | the receipts this report grades |
metadata | object | opaque, except the eligibility flag below |
The answer is {"agent_record_id", "scenario", "request_type"}. A report may
arrive before the inference it references. Reports and inferences race, and
the recipe's engine parks a report until its records land.
Set a top-level agent_record_id for retry-safe posting. Reef strips it from
the payload and uses it as the record id: an identical resend returns the
stored record and never re-triggers processing, while the same id with
different content is HTTP 409. Derive it from something stable in your runner.
Reef treats it as an opaque string and validates no producer-level contract.
Inference ids are always server-assigned.
client.report("code-repair", {
"agent_record_id": "myharness:run42:trial7",
"score": 1.0,
"references": [receipt],
"metadata": {"harbor": {"trial_id": "run42:7"}},
})
A recipe may declare a report schema. Every report is then parsed at ingress,
and a violation is HTTP 400 naming the broken field, so the record does not
fail later at training time. The schema sets a minimum, so extra
feedback/metadata keys stay legal. To keep a report out of the training
data, send "metadata": {"training": {"eligible": false}}. An explicitly
false eligible makes the report terminal and releases the records it
references without producing a sample. The field defaults to true.
There is one accept worth knowing about. A report whose references name records reef has already consumed and compacted returns HTTP 200 without being stored. Reef cannot tell a compacted record from one that never existed, so a late grader does nothing instead of failing.
Training tensors#
A provider-compatible response does not carry enough to train on. Weight
recipes need the exact sampled ids, and reef never reconstructs them from
decoded text. The tensors travel in a training block on the recorded
response:
{"training": {
"tokens": [101, 102, 201, 202],
"loss_mask": [1, 1],
"rollout_log_probs": [-0.2, -0.3]
}}
tokens is prompt ids followed by response ids. loss_mask has one 0/1 entry
per response token, must select at least one, and must therefore be strictly
shorter than tokens. rollout_log_probs has one finite value per response
token; loss families that need no behaviour proxy (sft) accept its absence.
A row that breaks any of these is rejected with a message naming the rule.
Usually the harness attaches nothing. A token-native inference backend colocated with the serving engine builds the block from the engine's own sampling output, and reef strips it from the client's copy of the response. A harness that samples elsewhere and already holds exact policy data may instead send the three fields at the top level of the request body, which reef reads as a fallback when the response carries no block. A plain chat backend provides neither, and it still exercises inference and record capture, but its records cannot feed a weight update. Which backend a deployment runs is covered in deploy-and-train.md.
Read the improved artifact back#
For weight scenarios there is nothing to pull, because the endpoint you already call serves the published head and reports the weight version that answered. For scenarios that evolve a harness tree, the improved artifact is files. The service renders those files, and the client writes them to disk:
| route | answer |
|---|---|
GET /reef/harness | the served tree: {artifact_version, parent_artifact_version, files, gate}, plus an x-reef-artifact-version response header |
GET /reef/harness/versions | {scenario, versions}, the full catalog, oldest first, each training row carrying the gate metrics of the step that published it |
GET /reef/harness/install | one self-contained POSIX sh script that ensures the vendor binary and lays down the tree |
All three take x-reef-scenario, are read-only, and ignore x-reef-recipe,
so they never create a scenario. An unknown scenario is HTTP 404, and so is a
scenario whose recipe serves no files. The manifest and install reads accept
?version= to address any catalog row instead of the head, answering 404 on
an unknown or unrestorable version. The install read also requires
?adapter= (pi, opencode, or an external descriptor): unknown is 404, and
a known adapter whose descriptor declares no install section is 400.
for row in client.harness_versions("code-repair"):
print(row["artifact_version"], row["operation"], row.get("metrics"))
version = client.harness_pull("code-repair", "./harness") # head
client.harness_pull("code-repair", "./harness", version=version) # pinned
harness_pull writes the served bytes and nothing else. It renders nothing,
validates nothing, and refuses the pull when a served path escapes the
destination. It records the version and file list in a .reef-harness-version
JSON sidecar inside the destination (client-side bookkeeping, never part of
the served tree). A repeat pull deletes the files the previous pull recorded
before writing, so moving to an older version leaves no newer files behind.
Production deployments should pin: pass version= explicitly and every
machine gets the same bytes.
Pulling an older version rolls back your copy only. To move the service's own
serving head, POST /reef/scenarios/{scenario}/rollback with
{"artifact_version": "…"}. Reef republishes that checkpoint as a new commit
at the next step instead of rewinding, so the step number stays a valid
fencing epoch. GET /reef/scenarios/{scenario}/versions is the catalog for
that decision. It returns the same rows newest first, the opposite order
from the harness catalog. Only a row marked restorable is a rollback target.
Agents that cannot be modified#
When the agent is a binary whose model endpoint is a config field,
reef_client.serve inverts the direction. The agent calls a local proxy as if
the proxy were the model, and the proxy handles session stamping, trajectory
capture and SSE.
python -m reef_client.serve --listen 29100 --upstream http://127.0.0.1:8900 \
--session-header x-my-session-id --stamp-tools-only
POST /_sessions {"id": "hw-3"} answers with a URL whose /s/hw-3 prefix is
the agent's base_url root, so concurrent sessions self-identify by path and
never share state. The prefix is stripped before forwarding, and control
endpoints are never forwarded at all. GET /_captures drains the captured
turns with their receipts, which is what your reporting code reads. The proxy
adds x-reef-scenario and the bearer token only through its extra_headers
config field, which the CLI does not expose, so a CLI-launched proxy in front
of reef needs the agent to send the scenario header itself.
Service endpoints#
Neither of these routes names a scenario. One is for a load balancer and one is for an operator:
| route | answer |
|---|---|
GET /healthz | {"ok": true}, and nothing else. Registered on its own, so it answers before any scenario loads |
GET /reef/status | the training worker's last error, any scenario preload errors, and for the training scenario its step, serving weight version, and checkpoint-storage state |
/reef/status reports the training side only. It is where a stalled trainer
or an unreachable checkpoint store becomes visible. For an inventory of
scenarios, read GET /reef/scenarios.
Status codes#
| status | cause |
|---|---|
| 400 | a malformed body, a missing or empty x-reef-scenario, a new scenario without x-reef-recipe or with one the deployment cannot resolve, a report violating the recipe's declared schema |
| 401 | missing or wrong bearer token |
| 404 | unknown scenario (with implicit creation off), unknown artifact version, unknown adapter, a scenario that serves no files |
| 409 | a recipe or base artifact conflicting with the binding, a record id resent with different content, an engine that reports no serving weight version |
| 502 | the upstream provider failed on its own account |
| 503 | the artifact store is unreachable, or inference kept losing the weight-update race until its deadline |
Upstream 4xx responses are relayed with the upstream's own message, because agents parse those to repair the next attempt.
Next, read define-a-recipe.md for what happens to these records, deploy-and-train.md for the service and driver that run them, and glossary.md for any term above.