README
¶
r42
r42 is an HCL-based configuration and execution engine for the world, the universe and everything reproducible AI research
workflows. A configuration describes a directed acyclic graph (DAG) of research
workflows, quality-control sessions, typed tools, artifacts, modules, variables,
and outputs. r42 plans the complete graph before it starts any model session,
then applies the immutable plan through the official GitHub Copilot SDK.
Why r42
A serious deep-research run is rarely one prompt followed by one answer. It is usually decomposed into many stages that search and reason from different perspectives, reconcile conflicting findings, and assemble a knowledge graph with a traceable chain from each conclusion back to its evidence and citations.
Many of those stages need their own quality gate. An independent session may audit every claim and inference against the captured sources, return specific issues to closed Research, and repeat that exchange through several rounds of revision. The files produced along the way also matter: reports, snapshots, datasets, and other artifacts must exist, be non-empty, and satisfy the contract expected by downstream stages.
Some handoffs need stronger guarantees than a natural-language response. A session can be given typed functions and required to finish a stage by calling one of them with a structured completion payload. Schema mismatches, missing fields, and invalid enum values are rejected and sent back for repair, just like failed research QC. Those functions and research stages must also be reusable across different investigations rather than copied into every workflow.
r42 makes this process explicit. Its Plan phase resolves the complete research DAG, validates typed tool schemas, and captures module boundaries before paid or stateful work begins. Apply executes only that immutable graph, runs independent stages concurrently, gives each stage an isolated workspace, and fails fast when a block cannot complete. QC loops, typed tools, artifacts, and whole subgraphs can be packaged as modules and reused by other research configurations.
This design provides:
- reviewable JSON plans before execution;
- explicit and implicit dependencies between research tasks;
- persistent Collection, Collection QC, and closed Research sessions, plus an optional Final QC session, per block;
- typed inline Go and external-process tools with stable IDs;
- Terraform-style modules installed with
r42 init; - isolated per-block workspaces for reports, snapshots, and other artifacts;
- live TUI or line-oriented progress for the expanded DAG, session activity, tool calls, and token usage;
- opt-in JSONL debug logs containing prompts, reasoning, messages, and tool events.
Install
r42 requires Go 1.25 or newer and the GitHub Copilot CLI executable. r42 uses the official GitHub Copilot SDK, which starts Copilot CLI as its local agent runtime, so the CLI must be installed even when the model is supplied through a bring-your-own-key (BYOK) provider. Follow the official Copilot CLI installation guide, or install it through npm with Node.js 22 or newer:
npm install -g @github/copilot
Then install r42:
go install github.com/lonegunmanb/r42/cmd/r42@latest
Authentication depends on the provider configuration:
- When a research block omits
model_provider, the SDK uses GitHub Copilot's default provider. Copilot CLI must then be authenticated with a GitHub account that has access to GitHub Copilot. - When a research block references a BYOK
model_provider, r42 passes that provider's endpoint and API key to the SDK. Copilot CLI is still the local runtime, but no GitHub account or GitHub Copilot subscription is required for the model call.
Model providers
A model_provider "name" block describes how the Copilot SDK connects to a
model API. It does not select a model: each research or QC session supplies its
own model and references the provider as model_provider.<name>. Omitting the
reference uses GitHub Copilot's default provider; setting it enables BYOK.
| Field | Required | Purpose |
|---|---|---|
label name |
Yes | Local provider name used by references such as model_provider.openrouter. |
type |
Yes | Protocol family: openai, azure, or anthropic. Use openai for an OpenAI-compatible API. |
endpoint |
Yes | Provider base URL passed to the SDK. Supply the API root, not a request endpoint such as /responses or /chat/completions. |
wire_api |
No | completions or responses; defaults to completions. Not valid for anthropic. |
transport |
No | http or websockets; defaults to http. WebSockets requires wire_api = "responses". Not valid for anthropic. |
headers |
No | Additional request headers as map(string). Values must be known during Plan. |
api_key |
No | Literal API key passed through the SDK's APIKey field. Prefer api_key_ref. |
api_key_ref |
No | Name of an environment variable containing an API key. r42 reads it during Apply. |
bearer_token |
No | Literal token passed through the SDK's BearerToken field. Prefer bearer_token_ref. |
bearer_token_ref |
No | Name of an environment variable containing a bearer token. r42 reads it during Apply. |
retry |
No | At most one nested retry policy. Omitted fields use r42's provider retry defaults. |
At most one of api_key, api_key_ref, bearer_token, and
bearer_token_ref may be set. Choose the authentication field required by the
provider instead of manually constructing an Authorization header. In
particular, an OpenAI-compatible API key should normally use api_key_ref,
which maps to the SDK's API-key setting rather than its bearer-token setting.
[!WARNING] Literal
api_keyandbearer_tokenvalues are marked sensitive and redacted from displayed plans, but saved.r42planfiles contain those values unencrypted. Preferapi_key_reforbearer_token_ref; these store only the environment variable name in the Plan and resolve the secret during Apply.
BYOK example
This example uses OpenRouter's OpenAI-compatible API. Set the key in the
environment that will run r42 apply:
$env:OPENROUTER_API_KEY = Read-Host -MaskInput "OpenRouter API key"
Then declare the provider and reference it from the research block:
model_provider "openrouter" {
type = "openai"
endpoint = "https://openrouter.ai/api/v1"
wire_api = "completions"
transport = "http"
api_key_ref = "OPENROUTER_API_KEY"
headers = {
"HTTP-Referer" = "https://github.com/lonegunmanb/r42"
"X-OpenRouter-Title" = "r42"
}
retry {
lifecycle_retries = 3
model_call_retries = 3
interval_seconds = 2
max_interval_seconds = 30
}
}
research "static" "summary" {
model_provider = model_provider.openrouter
model = "openai/gpt-4o"
system_prompt = "Act as a rigorous research analyst."
prompt = "Summarize the most important design tradeoffs in this repository."
}
api_key_ref is an environment-variable name, not the key itself. Plan can
therefore validate and save this configuration without reading the credential.
Apply fails fast if OPENROUTER_API_KEY is missing or empty. The OpenRouter
headers shown above are optional; other OpenAI-compatible providers generally
need only their base URL, model identifier, and authentication setting. See
OpenRouter's OpenAI SDK documentation
for its current endpoint and optional headers.
The nested retry block accepts the same five fields documented for research
retries: lifecycle_retries, model_call_retries, interval_seconds,
max_interval_seconds, and error_message_regex. Provider values become the
base retry policy for every research or QC session that references the provider;
session-level retry blocks can override individual fields.
Example
r42 init resolves a local directory or go-getter source and copies every
*.r42.hcl file plus its supporting resources into the active configuration
snapshot. The following configuration creates one research workflow:
research "static" "summary" {
model = "gpt-5.6-sol"
reasoning_effort = "medium"
system_prompt = "Act as a rigorous research analyst. Distinguish evidence from inference."
prompt = "Summarize the most important design tradeoffs in this repository."
permission = "approve_all"
}
Initialize referenced modules, inspect and save the plan, then apply it:
r42 init .
r42 plan --out research.r42plan
r42 apply research.r42plan
# Or plan and apply the initialized snapshot directly
r42 apply
# Read the outputs saved by the latest successful Apply
r42 output
r42 apply is the convenience form that plans <cwd>/.r42/config, prints the
plan as JSON, and immediately applies it. Source changes do not affect the
active snapshot until r42 init is run again. The overall Apply timeout
defaults to one hour and can be changed with --timeout. Each active Copilot
session also has a 15-minute inactivity watchdog, configurable with
--session-stall-timeout. Every SDK event and typed-tool handler start or finish
resets that one per-session deadline. If it expires, r42 aborts the stalled turn,
waits up to 10 seconds for the old send and all tracked tool, handler, or
subagent work to stop, resumes the same logical session when needed, and sends
one continuation message. If that continuation also stalls, or if an Abort or
termination barrier exceeds the bounded cleanup window, r42 fails the research
block instead of starting overlapping work. Session Close is bounded separately
so a stuck SDK disconnect cannot prevent the CLI from exiting.
Apply selects its progress UI with --ui=auto|tui|repl (default auto). An
interactive terminal at least 50 columns by 12 rows uses the Bubble Tea TUI;
redirected output, CI, and unsupported terminals use the line-oriented REPL
renderer. The TUI header shows the run directory, expanded research-task
counts, active and failed counts across the complete DAG, and cumulative input
plus output tokens across all workflow phase sessions. A module failure therefore
sets the overall status to FAILED even when its child research blocks never
started.
At 100 columns or wider, the TUI renders DAG, selected-node detail, and event timeline panels together. Below 100 columns it renders the focused panel at the full terminal width. Every terminal resize immediately recomputes this layout, clamps vertical and horizontal scroll positions to the new viewport, and requests a complete redraw. If a running TUI is resized below 50 columns or 12 rows, it shows a size warning until the window is enlarged again.
Navigate with Tab/arrow keys, PgUp/PgDn, Home/End, and horizontal
Alt+Left/Alt+Right scrolling. Enter folds a module and f toggles live timeline
following. Press q twice to cancel a run. The REPL renderer prints the initial
DAG, research activity, tool transitions, and parent or nested module
START/DONE/FAILED transitions.
Apply prints the pretty Plan JSON before execution and pretty outputs JSON after
success. These are two consecutive JSON documents intended for immediate human
inspection. Both progress renderers write to stderr. For a single JSON document
that is safe to pipe, use r42 output; for example,
r42 output | jq -r '.report_path'. Use --ui=repl to force stable
line-oriented progress or --ui=tui to require an interactive terminal.
Variables, locals, and outputs
A variable block declares a typed input to the root configuration or a module.
Its value is read as var.<name>. r42 requires type; description, default,
and sensitive are optional, and nested validation blocks can reject invalid
values during Plan. A variable without a default must be supplied by the caller.
locals gives names to expressions derived inside the configuration. Local
values are read as local.<name> and are not caller-settable inputs. An output
publishes a value after Apply. Root outputs are printed after Apply and saved in
<cwd>/.r42/state.json; r42 output prints the saved values as one JSON object.
Module outputs form the module's public interface and are read by its caller as
module.<module_name>.<output_name>.
variable "topic" {
type = string
description = "Subject to investigate."
validation {
condition = length(trimspace(var.topic)) > 0
error_message = "topic must not be empty."
}
}
locals {
normalized_topic = trimspace(var.topic)
}
research "static" "summary" {
model = "gpt-5.6-sol"
system_prompt = "Produce a concise, evidence-based summary and save it to ${artifact(\"summary\").path}."
prompt = "Research ${local.normalized_topic}; write the Markdown report with r42_write_markdown."
artifact "summary" {
type = "file"
path = "summary.md"
description = "Final Markdown research summary"
required = true
non_empty = true
}
}
output "summary_path" {
description = "Validated Markdown summary."
value = research.static.summary.artifact.summary.path
}
A research block exposes .result only when it configures
terminate_tool_id; the value is the accepted string-compatible output of that
typed tool. A normal assistant completion without a terminate tool can publish
artifacts, as above, but has no .result attribute.
Root variables can be assigned on plan or on an unsaved-plan apply. Both
Terraform-style single-dash flags and conventional double-dash flags are
accepted, and each flag can be repeated:
r42 plan -var 'topic="USD/JPY"' -var-file inputs.r42vars --out research.r42plan
r42 apply -var 'topic="USD/JPY"'
# These spellings are equivalent
r42 plan --var 'topic="USD/JPY"' --var-file inputs.r42vars --out research.r42plan
-var values are HCL expressions, so collection values retain their types, for
example -var 'regions=["us","jp"]'. A non-JSON variable file uses ordinary
HCL assignments:
topic = "USD/JPY"
regions = ["us", "jp"]
Golden resolves variable sources during Plan and later sources override earlier
ones: R42_VAR_<name> environment variables, r42.r42vars, sorted
*.auto.r42vars files, and explicit CLI assignments. Within repeated CLI
assignments, later values from the same flag kind win. Applying a saved
.r42plan does not re-evaluate variables because their planned values are
already captured in that file.
Research blocks
Each research "static" "name" block defines one known unit of work in the DAG
and owns persistent Collection, Collection QC, and closed Research sessions for
that unit's initial and repair turns, plus Final QC when configured. The subtype
forms part of the block address. Value references create
implicit dependencies between blocks; depends_on adds an explicit dependency
when no value is exchanged. for_each can expand one declaration into
independently addressed instances such as research.static.name["key"].
A research "dynamic" "name" block is still one planned DAG node, but its
tasks attribute is a list of complete research configurations materialized
when that node begins Apply. Use it when an upstream result determines how many
isolated research workflows are needed:
research "dynamic" "followups" {
tasks = [
for index, question in jsondecode(research.static.plan.result).questions : {
model = var.model
system_prompt = "Investigate one accepted follow-up question."
prompt = question
artifact = {
report = {
type = "file"
path = "${block_wd()}/${index}/report.md"
description = "Markdown answer for this follow-up question"
required = true
non_empty = true
}
}
retry = null
qc = null
}
]
}
There are no block-level defaults for dynamic tasks: each object carries the
same Collection, Collection QC, Research, tool, quota, artifact, retry, and
Final-QC fields needed by that task, including all collection_* fields.
Within a task, retry and qc are an object or null, while artifact is a
map of named objects. The tasks expression may be unknown in the saved Plan, but it
must be wholly known when the block starts Apply. Empty tasks succeed
immediately. Materialized tasks run concurrently under the same global and
module parallelism budgets as static research; one exhausted task failure fails
the dynamic block.
Set the optional block-level serial = true attribute to run the materialized
tasks one at a time in their original list order. Its default is false. This
setting prevents tasks within that dynamic block from overlapping; it does not
serialize the whole DAG, so other ready research blocks may still run under the
shared parallelism budget.
All task objects remain available through research.dynamic.<name>.tasks and
retain their declared fields plus resolved artifact values and, when a terminate
tool is configured, result. Tasks share the parent block_wd(); use a for
index or another stable key when separate subdirectories are required. The TUI
keeps the dynamic block as one DAG node but expands its task rows and research
count when materialization occurs.
Dependencies
Dependencies determine when a block becomes ready during Apply. A block starts only after all of its dependencies have completed successfully. Independent ready blocks can run concurrently, subject to the configured parallelism.
| Kind | How it is declared | When to use it |
|---|---|---|
| Implicit | Reference another block in an HCL expression. | The downstream block consumes an upstream value such as a result, artifact path, module output, provider, or tool ID. |
| Explicit | Set depends_on to a list of block traversals. |
The downstream block must wait for an upstream side effect or completion, but does not consume one of its values. |
Implicit dependencies
Golden walks HCL expressions during Plan. A traversal such as
research.static.collect.artifact identifies the referenced block and
automatically adds it as a dependency. In this example,
research.static.summarize cannot start until research.static.collect
succeeds because its prompt consumes the collected artifact
path:
research "static" "collect" {
model = "gpt-5.6-sol"
system_prompt = "Collect primary evidence and preserve its source URLs."
prompt = "Write the evidence to ${block_wd()}/evidence.md."
artifact "evidence" {
type = "file"
path = "evidence.md"
description = "Collected primary evidence"
required = true
non_empty = true
}
}
research "static" "summarize" {
model = "gpt-5.6-sol"
system_prompt = "Summarize only the supplied evidence."
prompt = "Read ${research.static.collect.artifact.evidence.path} and summarize it."
}
The reference serves two purposes: it passes the absolute artifact path into the
prompt and creates the DAG edge research.static.collect -> research.static.summarize.
The same rule applies to expressions such as research.static.collect.result,
module.sources.report, external_tool.fetch.id, or
model_provider.primary. Merely writing an address or filesystem path as plain
text does not create a dependency because it is not an HCL traversal.
Explicit dependencies
Use depends_on when ordering matters but there is no value to reference. Its
value is a list of block traversals, not a list of strings:
research "static" "notify" {
model = "gpt-5.6-sol"
system_prompt = "Record workflow completion in the external audit system."
prompt = "Record that evidence collection completed successfully."
depends_on = [research.static.collect]
}
Here research.static.notify waits for research.static.collect, but r42 does
not inject the collect block's result, artifacts, or transcript into the notify
session. If it needs any of those values, reference them directly and let r42
infer the dependency instead of adding a redundant depends_on entry.
Both forms are validated during Plan. A reference to an unknown block or a cycle
such as research.static.a -> research.static.b -> research.static.a fails
before any model session starts. If an upstream block fails during Apply, blocks
that depend on its successful completion are not run.
Session fields
| Field | Required | Purpose |
|---|---|---|
model_provider |
No | Default provider for every phase in this research workflow. It contains endpoint, authentication, transport, and retry defaults. When omitted, the Copilot SDK uses its default provider behavior. |
model |
Yes | Model identifier passed to the provider, for example gpt-5.6-sol. |
profile |
No | Copilot runtime profile used for model capabilities and built-in tools. Defaults to model; set it separately when a BYOK provider model should use another known model's runtime profile. |
reasoning_effort |
No | Non-empty provider-specific reasoning level. r42 passes it through without restricting the allowed names. |
system_prompt |
Yes | Instructions appended to r42's fixed research protocol system prompt. |
prompt |
No | Initial user task. When omitted, r42 sends a fixed start message. |
collection_model_provider |
No | Collection provider override. When omitted, Collection reuses model_provider. |
collection_tool_ids |
No | IDs of acquisition or snapshot-producing typed tools available only during Collection. This is where search and fetch tools belong. |
tool_ids |
No | IDs of typed tools available only to the closed Research synthesis session. |
tool_call_quota |
No | map(number) of non-negative per-session call limits. It may name configured Collection or Research typed tools, the terminate tool, or Copilot built-ins. Collection and Research keep separate counters. |
terminate_tool_id |
No | Typed tool that must return an accepted response before the stage can finish. Its output must be string-compatible and becomes research.static.<name>.result. Without it, a normal assistant completion ends the stage. |
allowed_tools |
No | SDK tool-name allowlist shared by Collection and Research. Mandatory r42 protocol tools are added even when omitted. |
disallowed_tools |
No | SDK tool-name denylist shared by Collection and Research. Research additionally blocks obvious network, shell, write/edit, glob, task, and user-input built-ins; read-only view, grep, head, and tail remain available. |
collection_skill_directories |
No | Skill roots available only during Collection. |
collection_skills |
No | Skills eagerly loaded only during Collection. |
collection_disabled_skills |
No | Skills disabled only during Collection. |
skill_directories |
No | Skill roots available only to closed Research; use path.module for module-owned skills. |
skills |
No | Skills eagerly loaded only during Research. Names come from SKILL.md frontmatter or the skill directory name. |
disabled_skills |
No | Skills disabled only during Research. |
collection_batch_size |
No | Number of newly registered unique snapshots that triggers checkpoint_pending. Defaults to 10. |
max_collection_rounds |
No | Maximum acquisition rounds, including the initial Collection phase. Omission means unlimited. |
permission |
No | Tool permission policy. The current supported value and default is approve_all, which approves each otherwise valid tool request. |
max_protocol_attempts |
No | Maximum repair budget for rejected terminal calls or completed turns that omit the required terminal call. Defaults to 10; a new QC revision round resets the budget. |
timeout |
No | Per-block deadline expressed as a Go duration such as 30m or 2h. It is bounded by the CLI and ancestor-module deadlines. |
Tool filters use SDK names. A typed tool's read-only .id is also its SDK name,
so the same ID can appear in collection_tool_ids or tool_ids and in the
filters. The mandatory registration, checkpoint, terminal, and verdict tools
cannot be disabled.
Every research block now starts in Collection, even when
collection_tool_ids is empty. Collection registers useful material through
r42_register_artifact, either from a workspace file path or from the retained
result of a configured typed tool call, and ends each round through
r42_collection_checkpoint. A checkpoint always contains every newly
registered evidence artifact; an empty checkpoint must explain why no new evidence is
needed. After collection_batch_size unique registrations, new acquisition
calls pause until the checkpoint is submitted, while in-flight completion,
registration, and checkpoint calls remain available.
By default, Collection, Collection QC, closed Research, and Final QC all reuse
the research block's model_provider. Collection can override it with
collection_model_provider; Collection QC and Final QC can override it with
their nested model_provider fields. An omitted override never clears the
top-level provider.
This changes the meaning of tool_ids: acquisition tools that previously
appeared there must move to collection_tool_ids. Research uses only registered
evidence artifacts through r42's typed readers, its explicitly configured trusted typed
tools, controlled Markdown output, and an optional termination tool.
skill_directories
A skill is a reusable set of instructions stored in a named directory. Each
entry in skill_directories points to a parent directory, and Copilot discovers
skills from its immediate subdirectories:
skills/
source-evaluation/
SKILL.md
citation-checking/
SKILL.md
experimental-browser/
SKILL.md
For example, skills/source-evaluation/SKILL.md can contain:
---
name: source-evaluation
description: Evaluate the authority, recency, and independence of research sources.
---
# Source evaluation
For every material claim:
1. Prefer primary sources over summaries.
2. Record the publication date and publisher.
3. Separate independent confirmation from repeated reporting of one source.
4. State any conflict, uncertainty, or missing evidence explicitly.
Use path.module to construct a module-owned absolute path. This remains valid
when the same module is installed below .r42/modules:
research "static" "market" {
model = "gpt-5.6-sol"
system_prompt = "Research the requested market and cite every material claim."
prompt = "Summarize the current market conditions."
skill_directories = ["${path.module}/skills"]
skills = ["source-evaluation"]
disabled_skills = ["experimental-browser"]
qc {
criteria = {
citations = "Every material claim must be supported by the captured sources."
}
skill_directories = ["${path.module}/skills"]
skills = ["citation-checking"]
disabled_skills = ["experimental-browser"]
}
}
skill_directories makes skills discoverable. skills selects the named skills
that r42 eagerly loads into that session's custom agent, while
disabled_skills prevents a discovered skill from being used. Names must match
the name in the skill's YAML frontmatter; when name is absent, Copilot uses
the skill directory name.
Collection, Research, and Final QC are independent sessions. Collection uses
the collection_* skill fields, Research uses the unprefixed fields, and Final
QC uses fields nested in qc. Collection QC deliberately has fixed read-only
capabilities and no skill fields. r42 records evaluated skill paths in the Plan
but does not copy or validate their contents, so the directories and
SKILL.md files must remain readable when Apply starts.
retry
A research block accepts at most one retry block. Its values override the
referenced model provider's retry policy; omitted fields continue to inherit the
provider value.
| Field | Purpose |
|---|---|
lifecycle_retries |
Additional attempts for session lifecycle operations. Provider default: 10; 0 disables retries. |
model_call_retries |
Additional attempts for transient model-call failures. Provider default: 5; 0 disables retries. |
interval_seconds |
Initial retry delay in seconds. Provider default: 10. |
max_interval_seconds |
Maximum backoff delay in seconds. Provider default: 180. |
error_message_regex |
Additional regular expressions that classify matching errors as transient. Built-in transient classifications remain active. |
Authentication errors, invalid schemas, unsupported model parameters, explicit cancellation, and deadline expiry are permanent failures and are not retried.
artifact "name" and artifact("name")
An artifact declares a file or directory that the session is expected to produce. A research block may declare multiple uniquely named artifacts. The declaration is also the source of the artifact metadata exposed to the block's sessions and to downstream blocks.
| Field | Required | Purpose |
|---|---|---|
label name |
Yes | Unique name used as research.static.<block>.artifact.<name> and by artifact("name"). |
type |
Yes | Either file or directory. |
path |
Yes | Expected path. Relative paths are based on block_wd(); absolute paths and .. are allowed. |
description |
Yes | Semantic description of the artifact's contents. It is shown to models and helps them choose what to read. |
required |
No | When true, the path must exist before QC or block completion. Defaults to false. |
non_empty |
No | When true, a file must contain bytes or a directory must recursively contain a regular file. Defaults to false. |
Missing required artifacts are repairable issues sent back to the same research session. Artifact metadata and normalized paths are also provided to QC.
[!IMPORTANT] An
artifactblock declares a postcondition; it does not instruct the model to create the file or directory. Every required artifact must also be explicitly requested insystem_promptorprompt, including its path and expected format, or be created by a typed tool that the stage is required to call throughterminate_tool_id. Otherwise the session may finish without producing it. r42 then reports the artifact problem back to the same session for repair; if the model continues to ignore it, this can look like a loop untilmax_protocol_attemptsis exhausted and the block fails.
Inside a static research block, artifact("name") returns the declared
artifact object. Its fields are id, name, kind, type, path,
description, required, and non_empty:
research "static" "collect" {
model = "gpt-5.6-sol"
system_prompt = "Save source material under ${artifact("sources").path}."
artifact "sources" {
type = "directory"
path = "sources"
description = "Markdown copies of retained primary sources"
required = true
}
}
Use .path for prompt text or a typed-tool input that expects a path, and
.id for a typed-tool input that expects an artifact ID. The type and
description fields tell the model whether it should read a file directly or
enumerate a directory first. artifact("name") refers only to an artifact
declared by the current block or dynamic task; use
research.static.<block>.artifact.<name> (or an import_artifact declaration)
when consuming another block's artifact. A reference to another block creates
the normal implicit DAG dependency. The function is resolved during Plan for
static blocks and after dynamic task materialization for dynamic tasks; its
run-scoped ID is assigned during Apply.
Built-in artifact typed tools
r42 mounts these typed tools automatically. They are available in addition to
user-configured go_tool and external_tool tools, and they never accept
filesystem paths where an artifact ID is required. The current block or task's
authorization, including imported artifacts and discovered files inside
authorized directories, is enforced by every read operation.
| Tool | Available in | Purpose |
|---|---|---|
r42_list_artifacts |
Collection, Collection QC, Research, Final QC | List authorized artifacts and their IDs, paths, types, and descriptions. Call this when an ID is uncertain. |
r42_list_artifact_files |
Collection, Collection QC, Research, Final QC | Enumerate regular files inside an authorized directory; use each returned child ID with a reader. |
r42_read_artifact |
Collection, Collection QC, Research, Final QC | Read a bounded page by ID. Use offset_bytes and next_offset_bytes to continue when truncated is true. |
r42_search_artifact |
Collection, Collection QC, Research, Final QC | Search one authorized artifact with a Go RE2 regular expression and return matched text plus context. |
r42_search_artifacts |
Collection, Collection QC, Research, Final QC | Search all authorized readable artifacts, including imported artifacts and directory children; each match includes its artifact ID. |
r42_read_artifact_json_schema |
Collection, Collection QC, Research, Final QC | Infer the JSON shape of one complete JSON artifact. |
r42_query_artifact_json |
Collection, Collection QC, Research, Final QC | Run a read-only jq query such as .claims[0].id against a JSON artifact. |
r42_write_markdown |
Collection and Research | Write content to a declared file artifact using artifact_id; it does not accept a path or artifact name. |
r42_save_artifact |
Collection | Save Markdown source material, add its source header, register it, and return path plus artifact_id. The source can be a URL or any non-empty identifier. |
r42_register_artifact |
Collection | Register an existing workspace file or retained typed-tool result; optional source and description can supply missing metadata. Do not call it after r42_save_artifact. |
r42_collection_checkpoint |
Collection | Submit newly registered evidence to Collection QC, or report that collection is exhausted. |
r42_collection_qc_verdict |
Collection QC | Return sufficient or needs_more with semantic QC issues. |
r42_qc_verdict |
Final QC, when configured | Return pass, revise_research, or reopen_collection with semantic QC issues. |
Collection is the only open-world phase: it can acquire evidence through the
configured collection tools and save/register it. Collection QC, Research, and
Final QC can use the read-only view, grep, head, and tail file tools in
addition to the built-in artifact tools. Closed Research still cannot use
shell, PowerShell, write/edit tools, or network acquisition. Built-in typed
tools return structured rejection issues so the model can correct an
invocation without guessing paths or IDs.
collection_qc
Collection QC is mandatory and persistent. An optional collection_qc block
overrides its model settings and semantic sufficiency criteria; when the block
is absent, Collection QC inherits the Research model settings and uses r42's
default sufficiency criterion.
| Field | Required | Purpose |
|---|---|---|
criteria |
No | Non-empty map(string) of semantic evidence-sufficiency checks. Omission uses the default criterion. |
model_provider |
No | Collection-QC provider override; otherwise reuses the research block's top-level model_provider. |
model |
No | Collection-QC model override; otherwise inherits Research. |
reasoning_effort |
No | Collection-QC reasoning override; otherwise inherits Research. |
permission |
No | Permission override; otherwise inherits Research. |
retry |
No | One retry block layered over the selected Collection-QC provider policy and then the research-level retry override. |
Collection QC can list and read registered evidence artifacts but cannot acquire or
modify evidence. It reviews the current checkpoint plus prior issues and calls
r42_collection_qc_verdict with either sufficient and no issues, or
needs_more and at least one concrete issue. A valid verdict advances the
reviewed cursor. needs_more starts another Collection round when budget
remains; when the configured limit is exhausted, the unresolved issues are
carried into closed Research instead.
qc (Final QC)
A research block accepts at most one qc block. It creates a persistent Final
QC session that reviews the closed Research candidate, artifacts, and registered
snapshots. Omitting qc completes the block after Research succeeds.
| Field | Required | Purpose |
|---|---|---|
criteria |
Yes | Non-empty map(string). Each key is a stable criterion ID and each value is the concrete review instruction given to QC. |
model_provider |
No | Final-QC provider override; otherwise reuses the research block's top-level model_provider. |
model |
No | QC model override; otherwise inherits the research model. |
reasoning_effort |
No | QC reasoning override; otherwise inherits the research value. |
tool_ids |
No | Typed tools available only to QC. Research tools are not inherited. |
tool_call_quota |
No | QC-only map(number) of non-negative call limits. Typed-tool ID keys must also appear in this QC block's tool_ids; ordinary keys limit Copilot built-in tools. |
allowed_tools |
No | QC SDK tool allowlist. The research allowlist is not inherited. |
disallowed_tools |
No | Additional Final-QC denylist. Final QC always blocks obvious network, shell, write/edit, glob, task, and user-input built-ins; read-only view, grep, head, and tail remain available. |
skill_directories |
No | Skill roots available only to QC; research skill roots are not inherited. |
skills |
No | Skills selected only for QC. |
disabled_skills |
No | Skills disabled only for QC. |
permission |
No | QC permission override; otherwise inherits the research permission. |
max_qc_rounds |
No | Maximum number of QC evaluations, including the first evaluation. Defaults to 10; at most max_qc_rounds - 1 QC-triggered research revisions can occur. |
retry |
No | One retry block using the same fields as research. It is layered after the selected Final-QC provider policy and the research-level retry override. |
Collection, Research, and Final QC quotas use independent counters, even when
sessions use the same tool. Typed-tool calls consume quota only after their arguments pass schema
validation and the tool returns an accepted response; execution errors and
accepted = false responses release the reservation. Built-in calls reserve
quota immediately before execution and release it when the SDK reports tool
failure. A limit of 0 disables the named tool for the session. r42 adds typed
limits to the typed-tool descriptions and built-in limits to the session system
prompt. Once a limit is exhausted, r42 denies the call before execution and
tells the model not to retry it during that session.
For each QC round, r42 sends one JSON context document to the QC session. It
contains the original task, the complete criteria map, the current candidate
result, and every declared artifact's normalized path and constraints. It does
not contain the research transcript. For example, the criteria in the example
below reach QC in this form:
{
"criteria": {
"value": "Read the report and verify that its USD/JPY value matches the cited source.",
"date": "Verify that the observation date is identified correctly.",
"citations": "Verify that every reported rate is supported by a source URL."
}
}
The map does not create three QC sessions or three independently executed
checks. One Final QC session receives the whole map and assesses every entry
before calling the mandatory r42_qc_verdict typed tool. It returns one of
three decisions:
pass: no issues; complete the block.revise_research: one or more issues; revise from existing snapshots.reopen_collection: one or more evidence gaps; acquire another snapshot batch before Research runs again.
For example:
{
"decision": "revise_research",
"issues": [
{
"code": "value",
"message": "The report says 151.2, but the cited snapshot says 150.8.",
"path": "D:/project/r42/.r42/runs/run-.../blocks/.../report.md",
"repair_hint": "Replace the rate with 150.8 and preserve the snapshot URL."
}
]
}
Use short, stable criterion keys such as value, date, and citations, and
write values as observable pass/fail requirements rather than broad goals. An
issue should normally reuse the failed criterion key as its code, but this is
currently a convention: r42 requires a non-empty issue code and message but
does not enforce that the code appears in criteria. path and repair_hint
are optional.
When Final QC finds issues
r42 validates every non-pass verdict contains at least one issue and every
issue has a non-empty code and message. revise_research sends those issues
to the persistent closed Research session. reopen_collection first checks the
shared Collection round budget. If a round remains, it returns to the persistent
Collection session; that acquisition round alone increments the count, and the
new checkpoint passes through Collection QC and Research before Final QC runs
again.
If max_collection_rounds is exhausted, the verdict tool returns an accepted
protocol response containing the repairable
collection_round_budget_exhausted issue. Final QC stays active and must choose
revise_research or pass using existing snapshots. A rejected reopen does not
consume a Collection round. If a non-pass decision arrives on the
max_qc_rounds evaluation, r42 starts no unreviewable follow-up work and fails
the block with final qc rounds exhausted.
The following example asks closed Research to write an exchange-rate report and gives Final QC three explicit checks:
research "static" "exchange_rate" {
model = "gpt-5.6-sol"
system_prompt = <<-EOT
Use current, authoritative sources. Cite the source URL for every reported
exchange-rate value and distinguish the observation date from publication
dates.
EOT
prompt = <<-EOT
Find the latest USD/JPY exchange rate and write a Markdown report to
${block_wd()}/report.md. Include the observation date, the rate, and source
URLs.
EOT
collection_batch_size = 10
# max_collection_rounds is omitted, so Collection may reopen without a limit.
collection_qc {
criteria = {
source_coverage = "The snapshots contain a current rate, observation date, and authoritative source URL."
}
}
artifact "report" {
type = "file"
path = "report.md"
description = "USD/JPY exchange-rate research report"
required = true
non_empty = true
}
qc {
criteria = {
value = "Read ${block_wd()}/report.md and verify that its USD/JPY value matches the cited source."
date = "Verify that the report states when the rate was observed and does not present a publication date as the observation date."
citations = "Verify that every reported rate has a source URL and that the source supports the claim."
}
max_qc_rounds = 3
}
}
After Collection and Collection QC finish, closed Research writes and validates
report.md, then Final QC receives the candidate, registered evidence artifacts, and the
artifact's normalized path. With
max_qc_rounds = 3, the sequence is bounded as follows:
| QC evaluation | Candidate being checked | Result when QC rejects it |
|---|---|---|
| 1 | Initial research result | Issues trigger research revision 1. |
| 2 | Revision 1 | Issues trigger research revision 2. |
| 3 | Revision 2 | The block fails; there is no revision 3. |
Thus the setting allows at most three QC evaluations and two QC-triggered research revisions. A pass during any evaluation completes the block immediately.
Modules
A module is a reusable directory of *.r42.hcl configuration and its supporting
files. It can package research blocks, QC policies, typed tools, variables,
outputs, and nested modules behind one declared boundary. Its variables are the
inputs supplied by the caller; its outputs are the public values that the caller
can reference. Internal research blocks are still included in the complete Plan
and executed during Apply, while internal tools remain private unless the module
explicitly exports their IDs.
module "source_review" {
source = "./modules/source_review"
topic = "evidence quality"
}
output "review" {
value = module.source_review.report
}
Modules keep repeated research stages and tool definitions in one place. A team can reuse the same source-gathering, claim-verification, or report-generation subgraph in many investigations without copying its prompts, schemas, QC rules, and helper programs. The boundary also makes dependencies explicit and limits what callers can access to declared outputs.
r42 init <source> resolves the complete root configuration package and copies
it to <cwd>/.r42/config, excluding nested .r42 and .git directories. A
local directory is read directly. Any non-local source locator is downloaded
through go-getter v2.2.3,
following Terraform's
getter-backed module source conventions.
This includes GitHub shorthand, git:: URLs, repository subdirectories, and
supported HTTP archives:
r42 init 'github.com/acme/research-config//r42?ref=v1.2.3'
# Initialize the chokepoint example directly from its Git repository subdirectory
r42 init 'git::https://github.com/lonegunmanb/r42.git//docs/examples/chokepoint'
Terraform Registry address and version negotiation are not implemented; use a concrete go-getter locator.
The same source rules apply to each module's literal source attribute. Modules
are installed under <cwd>/.r42/modules. Remote root sources are fetched into a
private staging directory before activation, so a failed download leaves the
current initialized project available. <cwd>/.r42/state.json records the
active local source path or a sanitized remote locator, the active snapshot
directory, and the outputs from the latest successful Apply. Its source identity
is always a SHA-256 value; URL credentials, fragments, and query parameters
other than ref are not persisted. A successful Init replaces this state and
invalidates outputs from the previous configuration.
Plan and unsaved-plan Apply accept no configuration directory and read only the
initialized snapshot. Root path.module is therefore <cwd>/.r42/config;
module blocks use their canonical installed directories. cwd() remains the
directory where the CLI was started. Run r42 init again after changing the
source configuration.
Typed tools
r42 supports two kinds of typed tool: go_tool and external_tool. Both expose
a JSON Schema to the model, receive validated structured arguments, and return a
common ToolResponse envelope. A rejected call contains actionable issues and
is returned to the session for repair; a process, I/O, cancellation, or protocol
failure fails the block instead of being disguised as a model mistake.
Every typed tool has a deterministic read-only id derived from its canonical
block address. Collection selects acquisition tools through
collection_tool_ids; closed Research and Final QC select their trusted tools
through tool_ids. Research can require an accepted call to one tool before
completion by setting terminate_tool_id:
collection_tool_ids = [external_tool.search.id]
tool_ids = [go_tool.build_report.id]
terminate_tool_id = go_tool.submit_report.id
tool_use
tool_use binds one configured typed tool to a research stage. It is the
preferred form when a tool needs a mixture of workflow-owned inputs and values
that the model must construct from authorized artifacts. A configured
tool_use replaces tool_ids and terminate_tool_id for that research block:
do not combine the two forms.
In a static research block, declare one nested block per tool. input fixes
fields from HCL and removes them from the JSON Schema shown to the model.
input_from_agent leaves named fields for the model and supplies a semantic
description plus the authorized artifact sources it should read. A field has
exactly one owner: it cannot appear in both maps.
research "static" "write_report" {
model = "gpt-5.6-sol"
system_prompt = "Write the report from the supplied evidence."
artifact "report" {
type = "file"
path = "report.md"
description = "Final Markdown report"
required = true
non_empty = true
}
import_artifact "evidence" {
desc = "Primary evidence collected by the upstream block"
sources = [research.static.collect.artifact.evidence]
}
tool_use "submit_report" {
tool_id = go_tool.submit_report.id
terminate = true
input = {
report_id = artifact("report").id
topic = var.topic
}
input_from_agent = {
summary = {
desc = "Evidence-based report summary"
sources = [research.static.collect.artifact.evidence]
}
}
validation {
condition = length(trimspace(input.summary)) > 0
error_message = "summary must not be empty"
}
}
}
tool_id is required. terminate = true is optional, but at most one
tool_use can terminate a stage. Its accepted string-compatible output becomes
the block's .result. validation blocks are optional; they can reference
only the special input object, run after fixed values are injected, and fail
the invocation when their condition is false.
Dynamic tasks use the same singular name and field semantics, expressed as a map because each task is an HCL object rather than a block body:
tool_use = {
submit_report = {
tool_id = go_tool.submit_report.id
terminate = true
input = {
report_id = artifact("report").id
topic = var.topic
}
input_from_agent = {
summary = {
desc = "Evidence-based report summary"
sources = [research.static.collect.artifact.evidence]
}
}
validation = [{
condition = "length(trimspace(input.summary)) > 0"
error_message = "summary must not be empty"
}]
}
}
The dynamic spelling is tool_use, not tool_uses. Its validation entries
are objects because dynamic task configuration is data. When a source is a
directory artifact, r42's generated prompt tells the model to call
r42_list_artifact_files and then read child IDs; for file artifacts it tells
the model to use the artifact readers or JSON query tools. Fields not listed in
input_from_agent are constructed from the current task's declared artifacts
when the tool schema requires them.
go_tool
A go_tool keeps its implementation directly in HCL as an inline Go source
fragment. It declares Input, Output, and a typed Invoke function. Plan
parses and type-checks the source, validates its signature and cty compatibility,
and permits only Go standard-library imports. Apply generates a small executable,
compiles it once per r42 process, and starts a fresh child process for each tool
call.
Use a go_tool for portable validation and transformation logic that should be
planned together with the research contract: validating a citation record,
normalizing a structured result, enforcing allowed enum combinations, or
accepting the final payload that ends a stage. Its implementation has no
third-party Go dependencies and needs no separately deployed script.
external_tool
An external_tool invokes an existing executable and declares its types with
HCL input_type and output_type constraints. For each call, r42 starts the
configured program, writes one JSON argument object to stdin, and expects one
JSON ToolResponse document on stdout. Its working directory defaults to the
calling block's workspace, so the process can create task-specific artifacts;
module-owned programs can be located with path.module.
The HCL declarations and the program's JSON protocol are one contract:
input_typedescribes the complete JSON value that the Python program reads from stdin. Every field name, nested object, array, and primitive value must have the declared shape. r42 appliesoptional(...)defaults and validates the value before starting the program.output_typedescribes the JSON value inside the stdout response'soutputfield. It does not describe the surroundingToolResponseenvelope. For a successful call, the program writes{"accepted":true,"output":<value matching output_type>}. For a repairable rejection it writes{"accepted":false,"issues":[...]}and omitsoutput.- stdout must contain exactly one JSON document. Missing fields, additional incompatible fields, wrong primitive types, or a different nested structure violate the contract and fail the tool call.
The equivalent Python runtime types are:
| HCL type | JSON representation | Python value |
|---|---|---|
object(...), map(...) |
object | dict |
list(...), set(...), tuple(...) |
array | list |
string |
string | str |
number |
number | int or float |
bool |
true or false |
bool |
In the example below, json.load(sys.stdin) must therefore produce a Python
dict with claim, sources, and minimum_confidence matching input_type.
The dict assigned to response["output"] must contain supported, verdict,
confidence, and citations matching output_type. The two HCL schemas and
these two Python data structures must be changed together.
An external tool can be implemented in any language. The only requirement is
that the program can read and write JSON and constructs values that exactly
match its declared schemas. r42 uses the same primitive and collection types
documented in HashiCorp's
Terraform type system,
including string, number, bool, list, set, map, tuple, and nested
object values.
For example, a claim-checking program can accept one claim plus a list of source excerpts and return a verdict with typed citations:
external_tool "check_claim" {
description = "Check one claim against supplied source excerpts."
program = ["python", "${path.module}/check_claim.py"]
input_type = object({
claim = string
sources = list(object({
url = string
excerpt = string
}))
minimum_confidence = optional(number, 0.8)
})
output_type = object({
supported = bool
verdict = string
confidence = number
citations = list(object({
url = string
quote = string
}))
})
}
The following check_claim.py is a minimal implementation. Its lexical matcher
is intentionally simple; a production tool can replace check() with a model,
database, or domain-specific verifier without changing the r42 protocol.
#!/usr/bin/env python3
import json
import re
import sys
from typing import Any
def tokens(value: str) -> set[str]:
return set(re.findall(r"[a-z0-9]+", value.lower()))
def reject(code: str, message: str, path: str) -> dict[str, Any]:
return {
"accepted": False,
"issues": [
{
"code": code,
"message": message,
"path": path,
"repair_hint": "Provide a non-empty claim and at least one source excerpt.",
}
],
}
def check(arguments: dict[str, Any]) -> dict[str, Any]:
claim = arguments["claim"].strip()
sources = arguments["sources"]
minimum = float(arguments.get("minimum_confidence", 0.8))
if not claim:
return reject("empty_claim", "claim must not be empty", "claim")
if not sources:
return reject("missing_sources", "at least one source is required", "sources")
claim_tokens = tokens(claim)
if not claim_tokens:
return reject("invalid_claim", "claim must contain searchable terms", "claim")
ranked = sorted(
(
(len(claim_tokens & tokens(source["excerpt"])) / len(claim_tokens), source)
for source in sources
),
key=lambda item: item[0],
reverse=True,
)
best_score, best_source = ranked[0]
confidence = round(min(0.99, 0.5 + 0.49 * best_score), 2) if best_score else 0.0
supported = confidence >= minimum
citations = []
if best_score:
citations.append({"url": best_source["url"], "quote": best_source["excerpt"]})
return {
"accepted": True,
"output": {
"supported": supported,
"verdict": "supported" if supported else "not_supported",
"confidence": confidence,
"citations": citations,
},
}
def main() -> int:
try:
arguments = json.load(sys.stdin)
response = check(arguments)
json.dump(response, sys.stdout, separators=(",", ":"))
sys.stdout.write("\n")
return 0
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as error:
print(f"invalid tool request: {error}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
For one call, the program receives this JSON on stdin:
{
"claim": "The release added parallel DAG execution.",
"sources": [
{
"url": "https://example.com/release-notes",
"excerpt": "The release added parallel DAG execution for independent ready vertices."
}
],
"minimum_confidence": 0.8
}
It must write one response matching ToolResponse[output_type] to stdout:
{
"accepted": true,
"output": {
"supported": true,
"verdict": "supported",
"confidence": 0.99,
"citations": [
{
"url": "https://example.com/release-notes",
"quote": "The release added parallel DAG execution for independent ready vertices."
}
]
}
}
Use an external_tool when the implementation belongs in Python, another
language, an existing CLI, or a program with dependencies that an inline
go_tool cannot import. Designing the schema and generating the small JSON
protocol adapter are well-suited to an AI coding agent: provide the desired
research contract and target language, then let the agent implement the parser,
response envelope, and tests. r42 still validates the declared types during Plan
and every actual value during Apply. The executable itself is required only at
Apply time, keeping runtime dependencies explicit.
Workspaces
Each applied research block gets a workspace below
<cwd>/.r42/runs/<run-id>/blocks; block_wd() returns that block-specific
absolute path, while path.module returns the absolute directory containing the
block's initialized configuration. Both functions use / path separators on
every operating system.
See the examples for a basic workflow and a module that exports typed external tools. The complete language and execution contract is documented in the design specification.
Safety
Saved plans and --debug logs may contain credentials, prompts, transcripts,
reasoning, and tool data. They are stored unencrypted under the paths selected
by the user or under <cwd>/.r42/runs; do not publish them or commit them to
source control. The live progress UI also displays assistant, reasoning, and
tool activity supplied by the model SDK, so terminal recordings and shared
screens should be treated as sensitive.
Development
Run the repository checks with:
go vet ./...
go test ./... -count=1
golangci-lint run
License
r42 is licensed under the MIT License.
Directories
¶
| Path | Synopsis |
|---|---|
|
cmd
|
|
|
r42
command
|
|
|
internal
|
|
|
collection
Package collection implements the mandatory Collection protocol tools: evidence-artifact registration, checkpoint submission, and the acquisition gate.
|
Package collection implements the mandatory Collection protocol tools: evidence-artifact registration, checkpoint submission, and the acquisition gate. |
|
collectionqc
Package collectionqc implements semantic review of Collection checkpoints.
|
Package collectionqc implements semantic review of Collection checkpoints. |
|
coordinator
Package coordinator composes the persistent Collection, Collection QC, Research, and optional Final QC phases for one research workflow instance.
|
Package coordinator composes the persistent Collection, Collection QC, Research, and optional Final QC phases for one research workflow instance. |
|
evidence
Package evidence provides restricted artifact access and controlled Markdown output for Collection, Research, and QC sessions.
|
Package evidence provides restricted artifact access and controlled Markdown output for Collection, Research, and QC sessions. |
|
workflow
Package workflow owns the pure research workflow state machine.
|
Package workflow owns the pure research workflow state machine. |