One run, step by step¶
This page follows one request through the workbench, from the command line to the files it
leaves behind. It is meant to be read first, before architecture.md,
conductor.md and grounding.md, which give the detail and the
reasons.
The output shown here was produced by running the commands below. Long hashes are shortened
with ….
Why there are so many steps¶
An agent, particularly one that calls a language model, can go wrong in ways that are hard to see from its answer alone. It may run where the institution has not allowed it. It may be given an input it does not understand. It may cite a source it never read, or a source that does not say what it claims. And once it has answered, nobody may be able to reconstruct what it did.
The Blueprint asks that every result can be traced to the person it was made for, the sources it rests on, and the steps that produced it. Each step of a run answers one question towards that:
| Step | Question it answers |
|---|---|
| 1. Policy gate | May this agent run here at all? |
| 2. Input check | Does this agent read this kind of input? |
| 3. The agent runs | What did the agent produce, and what does it say it did? |
| 4. Grounding linter | Does what the agent cites match what it says it did? |
| 5. Source check | Do the cited records exist, and say what the agent says they say? |
| 6. Validate and store | Is the response well formed, and can someone audit it later? |
The agent's author writes none of these steps. An agent is a specification and a run
function (see agents/example-hello/). The conductor applies every step, in the same way, to every agent
and from every front end.
The example¶
advisor.standards recommends vocabularies, ontologies and formats for a dataset from a
snapshot of the FAIRsharing registry. It is a retrieval agent: it fetches records, then
reasons over them. The input is
samples/soil-chemistry.profile.json, a description of
a planned dataset. Abridged:
{
"schema_class": "DatasetProfile",
"title": "Soil chemistry survey of upland grassland plots, Peak District, 2024",
"keywords": ["soil chemistry", "upland grassland", "organic carbon", "soil horizon", "Peak District"],
"themes": ["Soil science", "Environmental science", "Agriculture"],
"media_types": ["text/csv"],
"fields": [{ "name": "sample_id", "field_type": "string" }, "…"]
}
With the agents running (scripts/run-agents.sh) and DD_PRINCIPAL_ID and DD_PRINCIPAL_NAME
set (see the README), from workbench/:
uv run workbench invoke --agent advisor.standards --input samples/soil-chemistry.profile.json
The CLI wraps the input in a request, giving it a new invocation_id, and hands it to the
conductor. The viewer and the A2A endpoint do the same, so what follows is identical whichever
front end is used.
Before any step: is the request usable?¶
The conductor validates the request against the JSON Schema, checks that its invocation_id has
not been used before, and looks up the agent. A failure here is the caller's mistake, so the
conductor raises an error and records nothing.
It then sets what the agent must not decide for itself:
- the timestamps and the trace identifier;
- the input hash: a SHA-256 fingerprint of the input. The input is given the source id
input:<invocation_id>, so an agent that works only from its input can cite it like any other source; acting_for: the person the run is for, taken from the caller's authentication, not from the request (Policy and identity).
It opens the root span of the run's trace, invoke_agent. Everything that happens next is
recorded inside it.
1. Policy gate¶
The conductor asks the deployment's institutional profile (profiles/default.yaml unless
another is configured) whether advisor.standards is enabled, whether it declares the action class
the profile assigns it, and whether that class needs a data steward's approval.
Here it is enabled and needs no approval, so the run continues. Had it not been enabled, the
envelope would say failed with problem agent-not-permitted, and the agent would not be
called. Had it needed approval, the envelope would say referred. The profile's reference and
digest are recorded on every envelope, so a reader can see which rules applied.
2. Input check¶
advisor.standards declares that it reads DatasetProfile. The input is a DatasetProfile, so
the run continues.
Sending it the wrong kind of input shows what a refusal looks like:
uv run workbench invoke --agent advisor.standards --input samples/hello.salutation.json
"outcome": {
"status": "failed",
"statement": "advisor.standards accepts DatasetProfile, not Salutation. The agent was not run."
},
"problem": {
"type": "https://w3id.org/data-director/problems/input-not-accepted",
"detail": "Agent 'advisor.standards' accepts DatasetProfile; the request carried 'Salutation'."
}
From this point on, every problem becomes an outcome in the envelope rather than an error. The caller, the run store and a reviewer all see the same record.
3. The agent runs¶
The conductor calls the agent over A2A. The agent runs in its own process and sends back three things:
- an outcome and a payload: the answer, here 16 recommendations, each with a rationale;
- evidence: the agent's bibliography, one item per record it relied on, each with the record's id, where it came from, and a hash of its content;
- spans: the agent's diary of what it did, in OpenTelemetry format.
The payload names its sources in grounded_on, once for the whole payload and once for each
recommendation. One recommendation, abridged:
"payload": {
"schema_class": "Recommendations",
"items": [
{
"kind": "ontology",
"target": "dataset",
"score": 0.7375,
"rationale": "Chemistry vocabulary (FAIRsharing.TrcBD2) is recommended as an ontology … Identity was decided by retrieval and ranking, not by a model.",
"rationale_derivation": "template",
"classification_derivation": "lexical",
"grounded_on": [{ "source_id": "FAIRsharing.TrcBD2", "content_hash": "60d47253…" }]
},
"…"
],
"grounded_on": [
{ "source_id": "FAIRsharing.082881", "content_hash": "cdb45788…" },
"…"
]
}
The conductor adds the agent's spans to the run's trace. The finished tree, abridged:
invoke_agent advisor.standards, mode retrieval, input hash 8cda4f3c… (conductor)
├── policy_gate (conductor)
├── retrieval FAIRsharing.TrcBD2, hash 60d47253… (agent)
├── retrieval FAIRsharing.OsiqlF, hash c9b3d4ff… (agent)
└── … 25 more retrievals (agent)
The agent retrieved 27 candidate records and cites the 14 its recommendations rest on.
An agent that called a language model would also have a chat span, recording the model and
the token counts.
The conductor also checks that the payload is of the class the agent declared. A wrong class, or
an exception in the agent, becomes failed with problem agent-error.
4. Grounding linter¶
The agent has now given two accounts of its run: what it cites (grounded_on and its evidence)
and what it did (its spans). The linter checks that they agree. For a retrieval agent:
- every source in
grounded_onwas retrieved in this run, with the same hash (G2); - every evidence hash appears on a retrieval (G3);
- every source in
grounded_onis also in the evidence (G4); - any model call came after at least one retrieval (G1).
Here they agree:
grounding: passed [retrieval] (27 retrieval, 0 chat spans)
To see what a failure looks like, take the stored envelope and add a citation the agent never retrieved. The linter can be run over any finished run:
uv run workbench lint runs/<invocation_id>/spans.jsonl tampered.json
grounding: FAILED [retrieval]
- G2: payload rests on 'FAIRsharing.made-up' (000000000000…) which was not retrieved in this invocation
- G4: payload rests on 'FAIRsharing.made-up' (000000000000…) absent from evidence
In a live run, a violation turns a succeeded outcome into failed, with problem
grounding-violation, and removes the payload and the evidence. The caller does not receive an
answer that rests on something the run does not account for.
The linter is a consistency check. The spans are written by the agent, so a pass shows that the agent's story is consistent, not that it is true. That is what the next step is for.
5. Source check¶
The workbench holds its own copies of the sources agents cite, listed in
sources.yaml and pinned by hash. For each evidence item, the source check
looks up the record in the copy named by the item's snapshot_ref and hashes it again
(Evidence and grounding):
- the record must exist in the copy (S1), which catches an invented record;
- it must hash to the evidence item's
content_hash(S2), which catches a record the agent changed.
sources: 14 verified, 0 unresolved
The hash is what makes this possible. It is computed in a fixed, registered way (a
canonicalisation, Evidence and grounding), so the same record
always gives the same hash, and a single changed character gives a different one. Changing the
hash an envelope claims for FAIRsharing.082881:
uv run workbench verify tampered.json
sources: 13 verified, 0 unresolved; FAILED
- S2: evidence 'FAIRsharing.082881' hash 111111111111… does not match 'snapshot:2026-09-04', where the record hashes to cdb45788b725…
A violation withholds the result in the same way as a linter violation. An item whose source
has no copy in sources.yaml is reported as unresolved, and never counted as verified.
6. Validate and store¶
The finished envelope is validated against the JSON Schema. If what the agent returned breaks
the contract, the run is stored as failed with agent-error.
The envelope is then appended to runs/invocations.jsonl, and a folder is written for the run:
runs/<invocation_id>/
├── request.json the request as received
├── envelope.json the response returned
├── spans.jsonl the trace: the tree shown in step 3, with times
├── grounding.txt the linter's verdict
├── sources.txt the source check's verdict
└── crate/ a Process Run Crate: a standard provenance record of the run
Each file answers a question a reviewer may ask later: what was asked, what came back, what the
agent did, whether the checks passed, and, in a format other tools read, who ran what for whom.
DD_WRITE_CRATE=0 stops the crate being written.
The response¶
The envelope returned to the caller brings the pieces together. Abridged:
{
"invocation_id": "01a15ee0-4e05-769d-926e-a918903e19ea",
"agent_id": "advisor.standards",
"agent_version": "0.2.0",
"grounding_mode": "retrieval",
"policy_bundle_ref": "profile:default@v2",
"acting_for": { "name": "Josiah Carberry", "assurance": "asserted", "…": "…" },
"outcome": {
"status": "succeeded",
"statement": "16 recommendation(s) across controlled_vocabulary, data_format, field_format, ontology, terminology_unclassified, grounded in 14 FAIRsharing record(s) (snapshot:2026-09-04). Human review is required."
},
"payload": { "items": ["…"], "grounded_on": ["…"], "…": "…" },
"evidence": [
{
"source_id": "FAIRsharing.082881",
"source_uri": "https://fairsharing.org/713",
"snapshot_ref": "snapshot:2026-09-04",
"canonicalisation": "json-sorted-utf8-v1",
"content_hash": "cdb45788…",
"content": { "name": "Tabular Data Package", "…": "…" }
}
],
"telemetry": { "trace_id": "390dea2e7aab2aa1ca8d5f00f415ec08" },
"requires_human_review": true
}
Every field in it was either set by the conductor, or returned by the agent and checked by the steps above.
Other kinds of agent¶
The steps are the same for every agent. What changes with the agent's grounding mode is which
rules the linter applies (grounding.md):
input_onlyandnoneagents may not retrieve anything, and cite only the input, by theinput:<invocation_id>id and the input hash the conductor computed.noneagents may not call a model either.example.hellois the simplest example.delegationagents, such as the orchestratororchestrator.stub, ask the workbench to run other agents for them. Each of those runs goes through every step on this page as a run of its own, and the orchestrator may cite only runs the conductor recorded (Orchestration).
What the checks do not show¶
The checks show that a result is consistent with its sources, not that it is correct. A pass
does not show that a model's rationale is faithful to the records it cites, or that a
*_derivation badge is accurate. Those are matters for evaluation
(Evaluation and conformance) and for the human review that every envelope asks for.