Guides
Grade agent runs
rystic grid <agent> -evals <dir> runs your agent once per eval file. Each run, a cell, gets its own simulators, loaded with the eval’s scenario, behind a strict intercept gateway of its own. The agent calls the vendor’s real hosts unchanged, and no two cells share state.
When the agent stops, the cell is graded from the files it saved: PASS, FAIL or ERROR, with one reason. rystic grade grades a saved cell again without rerunning the agent. A coding agent does the same through rystic mcp: Snapshots and test cases.
An eval file
An eval names the scenario each simulator loads, the job the agent gets and the checks that grade the run. This one asks for a Stripe customer:
rystic pull stripe-twin
mkdir -p evals add-customer
printf '%s\n' 'world:' ' stripe: design_studio' \
'prompt: Add a customer named Ada Lovelace with the email ada@example.com.' \
'checks:' \
' - forbid: {ref: stripe.after.state_customer, changed: [email]}' \
" desc: changed an existing customer's email" \
' - expect: {ref: stripe.after.state_customer, new: true, where: {email: ada@example.com}, count: 1}' \
' desc: no new customer for Ada' > evals/new_customer.yaml
| Key | What it holds |
|---|---|
world | Required. Each simulator, named without -twin, mapped to the named scenario it loads. Checks name the simulators the same way |
prompt | Required unless inject is set. The job, handed to the agent the way its agent.yaml says |
inject | Per simulator, timeline events posted to /_rystic/inject when the cell starts, for an agent that waits for a request instead of reading a prompt |
request | The id of the event that is the agent’s request. A {request: <field>} matcher reads that event’s call arguments |
checks | Required. The checks, run in order |
allow_delete | The deletions and destructive changes the task calls for, each {ref, where} on a before ref |
Checks
| Check | Passes when |
|---|---|
expect: {ref, …} | at least one row matches, or exactly count rows |
forbid: {ref, …} | no row matches |
script: <path> | the script, run with the cell directory as its one argument, prints no FAIL <name> (<reason>) line. It prints PASS <name> lines otherwise; a script that prints neither is an ERROR. The path is relative to the cell directory, so an eval’s own script lives in a subfolder of the agent’s checks/, named as checks/<eval>/<file> |
refnames the rows:<simulator>.before.<register>and<simulator>.after.<register>are a register’s rows before the agent started and after it stopped.<simulator>.callsis the call log, and<simulator>.egressthe egress log.new: truekeeps theafterrows whose keybeforelacks.changed: [fields]keeps theafterrows where one of those fields differs frombefore.wheremaps a field to a matcher, and a row matches when every field does. A dotted field reads into nested objects. A literal matches a deeply equal value; an object is one operator:not,in,contains,existsorrequest.descis the reason a failing check gives the cell. The first failing check’sdescis the cell’s reason, so list a specific wrong outcome as aforbidbefore theexpectit would otherwise fail.- Nothing destructive runs before your checks. A row in
beforethat is gone fromafter, or a change the simulator lists as destructive, fails the cell unless anallow_deleteentry matches it.
The agent
The agent is a folder carrying agent.yaml, or a command after --. This agent is one curl call to Stripe’s real host, https://api.stripe.com:
printf '%s\n' 'name: add-customer' 'start: sh agent.sh' > add-customer/agent.yaml
printf '%s\n' 'curl -s --proto-default https api.stripe.com/v1/customers -u sk_test_agent: -d name="Ada Lovelace" -d email=ada@example.com' > add-customer/agent.sh
| Key | What it says |
|---|---|
name | Required. Names the agent’s row and cells: lowercase letters, digits, ., _ and - |
start | Required. The command, run with sh -c from the agent’s folder |
done | exit, the default, ends the cell when the process exits. idle <duration>, such as idle 30s, ends it once no simulator has served a request for that long |
events | poll, the default, turns no push on. socket is Slack Socket Mode. webhook <port> <path> has the simulators deliver events to http://127.0.0.1:<port><path> |
allow | Real hosts the agent may reach besides the simulators, such as its model’s API. The gateway refuses every other host |
prompt | arg replaces {prompt} in start with the eval’s prompt, shell-quoted. file writes it to a file named by RYSTIC_PROMPT_FILE. Omitted, it is arg when start carries {prompt}, and file otherwise |
The agent keeps its own environment and keys. The cell adds the gateway’s proxy and CA variables, as rystic exec does, and the simulators accept any well-formed key.
Run the grid
rystic grid add-customer -evals evals -out run1
# -> grid: add-customer × 1 evals, 2 at once → run1
# -> ...PASS new_customer...
# -> 1 of 1 cells passed → run1
-evalstakes a directory, whose.yamlfiles are the evals, or a comma-separated list of files.-outmust not exist yet; it defaults togrid-<timestamp>.-jsets how many cells run at once (default2).-timeoutcaps the agent’s wall-clock time (default5m), and an agent still running at the cap fails as “never finished”.- Each cell’s clock moves one simulated second per wall second.
-pace 0keeps it manual, and-clock-stepthen moves it that far each wall second.-seedpins every cell’s random stream (default1). - A cell passes on its verdict, never on the agent’s exit code. Any cell that is not PASS exits
7. -uiserves a grid page on a local port: rows go green or red as cells finish. Run selected and Run failing rerun those cells with the agent folder as it is now, and Run all reruns every cell. It serves until you interrupt it.
Each cell keeps its files under <out>/<eval>/: cell.json, the eval.yaml it ran, agent.log, verdict.json and per simulator twins/<name>/before.json, after.json, calls.json, egress.json, world.json and twin.log. <out>/ledger.jsonl holds one row per run of a cell, with its verdict, reason and checks digest.
Grade a saved run
rystic grade takes cell directories or a grid’s -out directory, and grades each cell from its saved files, never a live simulator:
rystic grade run1
# -> PASS new_customer
# -> 1 of 1 cells passed
jq -c '{verdict, reason}' run1/new_customer/verdict.json
# -> {"verdict":"PASS","reason":""}
It rewrites each cell’s verdict.json, exits 7 when a cell is not PASS and 1 when one cannot be graded. ERROR means the infrastructure failed, not the agent.
Your own checks: checks/
Checks your evals all share go directly in the agent folder’s checks/: executable scripts and YAML files holding a checks list and an optional allow_delete. Each one runs in every cell; a subfolder runs only where a check names it. Grid copies the folder into each cell, and they run after the eval’s own checks. A script a YAML file names is relative to checks/:
mkdir -p add-customer/checks
printf '%s\n' 'checks:' \
' - forbid: {ref: stripe.after.state_customer, new: true, where: {email: {contains: example.com}}}' \
' desc: created a customer with a placeholder email' > add-customer/checks/real-emails.yaml
rystic grid add-customer -evals evals -out run2; echo "exit $?"
# -> grid: add-customer × 1 evals, 2 at once → run2
# -> ...FAIL new_customer ... created a customer with a placeholder email...
# -> 0 of 1 cells passed → run2
# -> each cell's verdict.json, agent.log and twins/*/twin.log are under run2
# -> exit 7
Draft an eval from a good run
rystic grade --draft <cell> grades nothing. It prints an eval drawn from one finished cell, with the cell’s scenarios, prompt and injected events:
rystic grade --draft run1/new_customer > evals/new_customer_draft.yaml
head -3 evals/new_customer_draft.yaml
# -> # DRAFT: rystic grade --draft drew these checks from one recorded run.
# -> # Agent: add-customer. Eval: new_customer.
# -> # They expect every row the run created or changed, forbid every other
Its checks expect each row the run created or changed, and forbid any other new row, changed field, caller or method. allow_delete names each row the run deleted or changed destructively. Ids and times the run generated stay out. A run that never finished, or that would fail its own draft, prints no draft and exits with an error. Trim the checks to what the task requires before you keep the file.
Command evaluators
A simulator with an evaluation engine, today GitHub for its workflow runs, hands the commands it needs run to rystic evaluator. It claims each one, runs it in a digest-pinned container with no network and reports the outcome:
rystic pull github-pulls-twin
rystic run github-pulls-twin -d --name ci --port 0
rystic evaluator --instance ci --once 2>&1
# -> ...evaluator: completed 0
rystic stop ci
- It runs until
Ctrl-C;--onceruns what is waiting, then exits.--url <base>names a simulator thatrystic psdoes not list. - A command exits
0to pass,1to fail and78to decline the input, or writes its outcome to the file$EVAL_RESULTnames. Anything else is an infrastructure error. --runnerisdocker(the default) orpodman.--runner local --trusted-localruns local commands with host access, for trusted code only.--concurrencysets how many run at once (default2).