Harnesses and coaching

gptr logo

peter() wraps a language model in instructions, tools, skills, permission checks, budgets and undo. That wrapping is called a harness. A harness of your own helps when you give Peter the same kind of task again and again. It can fix the arguments you always pass, tell Peter what to know about your data, ask it to check its work before it stops, and set hard rules for the task.

Two functions make one. Both return a harness: a function you call exactly like peter().

The tailoring examples run without a model or a key. The coaching examples need a configured model and are shown without running them.

Tailor a harness by hand

gptr_tailor() starts from peter or from another harness. ... fixes named peter() arguments other than the prompt, such as mode, model, tools, budget or extensions. rules = holds your own hard rules, and coaching = holds what gptr gives Peter, in four parts.

library(gptr)
survey_peter = gptr_tailor(
  peter,
  mode = "auto",
  rules = list(
    never = list(list(rule = "write(data/raw/**)", why = "The raw data are read-only.")),
    require = list(list(id = "save-results", any = "r(fn:write.csv,saveRDS)",
                        text = "Save the final table with write.csv() or saveRDS()."))
  ),
  coaching = list(
    note = "An age of -9 means missing. Recode it to NA before any summary.",
    skills = list(list(
      name = "survey-missing-codes",
      description = "How this survey codes missing values.",
      text = "Age, income and weight use -9 for missing. Check each one with table() first."
    )),
    review = list(text = "Open the result again and compare it with the task.", rounds = 1)
  )
)
survey_peter
#> <gptr_harness c512fad4914c> built on peter (gptr 1.0.0)
#> fixed: mode = "auto"
#> your rules: 1 never, 1 require
#> coaching: note 63 characters, 1 skill(s), review 1 round(s), rules none
#> record: none

Rules use the language of ?gptr_permissions: r(fn:name) for a function, r(category:name) for a kind of action such as install or network, r(sh:glob), r(sql:keyword), or a path glob such as write(data/raw/**). A never rule (up to 8) refuses a matching call in every mode, also in sub-agents, with “denied by your rule” and your reason. A require rule (up to 4) names an action that must happen before Peter finishes: any patterns, optionally with contains text the code must hold, or made = "plot" or made = "file:results/**". When Peter is about to finish without it, gptr sends text, at most twice, and a run that still lacks it ends with a notice naming id. A require pattern may name only low-risk functions and categories (below level 3 in gptr_risk()). Rules read the code before it runs, so a path computed at run time can slip past a never rule. Treat require as a reminder and a report, not as proof.

Coaching has four parts. The note (at most 8,000 characters) is added to Peter’s instructions on every call, after a fixed line saying that it never overrides the rules, a permission decision or your request. skills are how-to guides that Peter opens when it needs them, named with lower-case letters, digits and hyphens. The review text is sent when Peter is about to stop, so that it looks again, for 0 to 2 rounds. rules have the same shape as your rules, and their refusals say “denied by the coaching rule”. Coaching is plain text and rule strings, checked whenever a harness is made, loaded or called.

Call and change it

survey = read.csv("survey.csv")
session = survey_peter("Clean the survey and tabulate the mean age by site.", survey)
session |> survey_peter("Add the median age too.")

Each call is the same call as peter() with the fixed arguments added, so objects, envir, {name} interpolation, gptr_last(), pipes, lapply() and do.call() work as usual. An argument you give in the call wins over a fixed one, except that tools, extensions, plugins and skills add to the fixed values and .opts entries replace them by name. The coaching applies only in the sessions the harness starts.

A harness is read-only. Read it with $coaching, $record and $report, and change it by making a new one from it. Fixed arguments merge as in a call, and NULL drops a fixed single value, as in mode = NULL. rules = and coaching = replace the whole part, and rules = list() drops your rules. To change one coaching part, start from the old coaching:

co = survey_peter$coaching
co$review = NULL
quick_peter = gptr_tailor(survey_peter, mode = "edits", coaching = co)
quick_peter
#> <gptr_harness a8444cb946de> built on peter (gptr 1.0.0)
#> fixed: mode = "edits"
#> your rules: 1 never, 1 require
#> coaching: note 63 characters, 1 skill(s), review none, rules none
#> record: none

Coach a harness

With the default settings, gptr_coach() works in five steps:

  1. Try. The starting harness does your task once.
  2. Propose. Two coaches read that run. One looks at how Peter works: its steps, tools, checks and rules. The other adds what an expert in the subject knows. Neither sees the other’s work, and neither can read files or act. They only write coaching.
  3. Probe. Each proposal does the task once.
  4. Compose. A third coach, the composer, compares the runs and keeps the changes that visibly helped. Keeping the starting coaching is an allowed answer.
  5. Final try. The composed coaching does the task once more.

Code, not a model, then decides. The new coaching is kept only when the final run ended normally, delivered at least what the starting run delivered (a value, objects, files, or else text), took no riskier kind of action, met every require rule and passed your check.

survey = data.frame(id = 1:8, age = c(34, 51, -9, 28, 45, -9, 62, 39))
want = mean(c(34, 51, 28, 45, 62, 39))
auto_peter = gptr_tailor(peter, mode = "auto")
mean_peter = gptr_coach(
  "Ages of -9 are missing. Recode them, then return the mean age with gptr_return().",
  survey,
  harness = auto_peter,
  check = function(r) is.numeric(r$value) && isTRUE(all.equal(r$value, want)),
  model = "anthropic/claude-sonnet-5-5",
  coach = "anthropic/claude-opus-5-5",
  budget = list(cost = 1)
)
mean_peter$record$decision
mean_peter("Ages of -9 are missing. Return the mean age with gptr_return().", new_survey)

... takes what you would give the harness: the prompt, your objects, and peter() arguments such as mode, model, budget or tools. They apply to every run of this call only and are not kept in the result; to keep a setting, put it in the starting harness, as auto_peter does. check gets r, a list with the text, status, value and objects of a final run, and returns TRUE or FALSE. It runs only in your R session, and no coach sees it. It is optional, but it is the only place where you say what a good result is.

Argument What it sets Default
harness the harness to start from peter
check your test of a final run none
coach the model of the coaches, the composer and the judge the runs’ model
k how much coaching, 1 to 5 1
strategy "parallel" or "sequential" "parallel"
focus one proposing coach each: "procedure", "domain" or your own words both
surface the parts of the coaching that may change all four

With "parallel", k gives k starting runs, k coaches of each focus and k composers that each see every probe; when more than one final passes, a blind judge that sees only the task and what each final delivered picks one. With "sequential", k rounds follow the starting runs, each building on the round before. Only the procedure coach writes rules. Coaching with the runs’ own model at k = 1 is untested, so k = 2 or a stronger coach model may help.

Cost. A call makes k * (2 + length(focus)) runs of your task and k * (1 + length(focus)) coach sessions, plus up to one judge session when k > 1: 4 runs and 3 coach sessions with the defaults. A budget, given here or fixed in the starting harness, caps each run, each coach session and the judge, so a default call can spend up to seven such budgets. gptr prints the count and asks the usual question about sending your data to each model before it starts.

Where it runs. Every session runs unattended in its own fresh R process, with copies of the objects you passed and an empty folder. A run does not see the rest of your workspace or your attached packages, and cannot install packages or write outside its folder by the routes gptr recognizes. Pass data as objects and install what the task needs first. A step that needs your approval ends that run as blocked, so most tasks need mode = "auto".

What you get back. A harness: the starting harness with the new coaching when it was kept (gptr prints the coaching in full), otherwise the starting harness and the reason. Its $record holds the decision and each run’s outcome, turns, tokens, cost and risk, but no data from your runs. $report holds every answer, transcript and coach reply, in this R session only. Kept is not proof that the harness does better on new data. Read the coaching before you rely on it or share it: it can mention facts from your data.

Coach on a Claude Code or Codex plan

Coaching works on a Claude Pro or Max plan through Claude Code, and on a ChatGPT plan through Codex. Name the plan model for the runs, the coaches, or both, and mix routes as you like:

plan_peter = gptr_coach("Recode the missing ages, then return the mean age.", survey,
                        harness = auto_peter, model = "claude_code", coach = "claude_code")
mixed_peter = gptr_coach("Recode the missing ages, then return the mean age.", survey,
                         harness = auto_peter, model = "codex",
                         coach = "anthropic/claude-opus-5-5")

"claude_code" is another name for "claude-cli". With no model configured and no Anthropic, OpenAI or Google key, gptr uses Claude Code when it is installed and otherwise Codex, so a plan-only setup needs no model. Set up the CLI as in Language models.

Routers and System 1 decision models are refused as run or coach models. These limits apply only inside gptr_coach(); a harness you call yourself accepts every model that peter() accepts.

Save, share and reuse

path = tempfile(fileext = ".rds")
saveRDS(survey_peter, path)
their_peter = gptr_tailor(readRDS(path))
their_peter
#> <gptr_harness c84d018803e7> built on peter (gptr 1.0.0)
#> fixed: mode = "auto"
#> your rules: 1 never, 1 require
#> coaching: note 63 characters, 1 skill(s), review 1 round(s), rules none
#> record: none

gptr_tailor(readRDS(file)) rebuilds the harness with a new id, checks its coaching, and shows any fixed argument that loads code or loosens checks, such as extensions, plugins, mode = "yolo" or a system prompt in .opts. Treat a harness from someone else like an R script from them: functions among its fixed arguments run when you call it, and readRDS() itself can load packages. A coach block in a recorded script (below) holds the coaching as literal text, never your fixed arguments or functions.

dir = writes the harness out as plain Markdown files for people to read or keep in git. gptr never reads that folder back, so keep the .rds file or a recorded script to load it again. Any harness can start a new coaching call, including one someone else made:

gptr_tailor(survey_peter, dir = "survey_peter")
my_peter = gptr_coach("Clean this year's survey.", survey_2026, harness = their_peter)

Safety

Recording coaching in a script

In a recorded script or notebook (Record, replay and export), a top-level harness call is recorded like a peter() call, and its header adds harness=<name>:<hash>. When the coaching changes, the block becomes stale, as when you edit a prompt. A top-level name = gptr_coach(...), with harness left out or a plain name, is recorded as a block of kind coach, whose body rebuilds the result with gptr::gptr_tailor() and lists each run and the decision:

mean_peter = gptr_coach("Ages of -9 are missing. ...", survey, harness = auto_peter)
# >>> gptr:7f3a1c ... kind=coach harness=mean_peter:9c41e2d0 start=auto_peter:5b0e77a1 ...
mean_peter = gptr::gptr_tailor(auto_peter, coaching = list(
  note = "Recode -9 to NA before you average, and check the result with table().",
  skills = list(),
  review = NULL,
  rules = NULL))
## Run start1: ended normally, 4 turns, risk 1
## Run probe-proc1: ended normally, 3 turns, risk 1
## Run probe-dom1: ended normally, 3 turns, risk 1
## Run final1: ended normally, 3 turns, risk 1
## Decision: kept (delivered as at start; risk 1 <= 1)
# <<< gptr:7f3a1c

On replay, gptr reads the body as literal text without running it and returns the same harness with no model, no key and no R process, so GPTR_REPLAY=replay still proves that a script makes no model call. The replayed harness carries the recorded coaching but an empty $record; the decision is the block’s ## Decision: line. Define the starting harness earlier in the script, since the body names it. Only the coaching and one line per run are written: never your data, transcripts, values or check. A call that errors or stops at a plan limit writes nothing, and gptr_export() refuses a document that holds a coach block.

Read more