GuideDocsIntegrate

Connect your harness

Connect a harness in three steps:

  1. Send OpenAI- or Anthropic-compatible model requests to reef instead of directly to the provider.
  2. Keep the receipt returned with each response and post feedback against it.
  3. 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#

headerrequired onmeaning
x-reef-scenarioinference, report, every /reef/harness* readwhich scenario the record belongs to; scenarios never share records
x-reef-recipethe request that first creates a scenariothe recipe the scenario binds to, for its lifetime
x-reef-artifact-versionoptional, creation onlythe base artifact the scenario forks from
Authorization: Bearer <token>every route except /healthzwhen 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:

routebodyanswer
POST /reef/scenarios{"name", "recipe", "artifact_version"?}{scenario, recipe, artifact_version}, 201 when created, 200 when it already existed
GET /reef/scenariosnoneevery known scenario, with its recipe and current artifact version once loaded
GET /reef/scenarios/{scenario}/contractnone{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:

fieldtypenotes
scorenumberoptional; a bool is not a number and is rejected
feedbackstring or objectopaque to reef's core: a rubric, judge output, plain text
referenceslist of stringsthe receipts this report grades
metadataobjectopaque, 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:

routeanswer
GET /reef/harnessthe 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/installone 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:

routeanswer
GET /healthz{"ok": true}, and nothing else. Registered on its own, so it answers before any scenario loads
GET /reef/statusthe 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#

statuscause
400a 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
401missing or wrong bearer token
404unknown scenario (with implicit creation off), unknown artifact version, unknown adapter, a scenario that serves no files
409a recipe or base artifact conflicting with the binding, a record id resent with different content, an engine that reports no serving weight version
502the upstream provider failed on its own account
503the 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.