Human in the loop
Harn's human-in-the-loop surface is first-class typed syntax.
ask_user, request_approval, dual_control, and escalate_to are
reserved keywords parsed as language-level expressions (not regular
function calls). The VM enforces waiting, timeouts, quorum,
escalation, signed receipts, the event log, and replay determinism, and
since the names are reserved, user code cannot shadow them, redefine
them, or compose around them to bypass approval.
Use import "std/hitl" when you want shared type aliases such as
ApprovalRecord or EscalationHandle. The keywords themselves are
always available — no import is required.
Call shapes: named or positional
Each primitive accepts either named arguments (the recommended form) or the legacy positional shape. Both lower to the same VM-enforced runtime, so existing scripts keep working without migration:
// Named-argument form (recommended)
let record = request_approval(
action: "deploy production",
quorum: 2,
reviewers: ["alice", "bob", "carol"],
)
// Positional form (kept for back-compat with the original stdlib API)
let record = request_approval(
"deploy production",
{quorum: 2, reviewers: ["alice", "bob", "carol"]},
)
The typechecker validates required arguments and rejects unknown names
per primitive (e.g. request_approval(bogus_arg: 1) is a compile-time
error).
VM-enforced invariants
Because the four primitives are language-level keywords:
- Names cannot be shadowed.
let request_approval = "fake"is a syntax error (reserved keyword), so user code cannot replace the primitive with a counterfeit function or value. - Approval cannot be bypassed by composition. The result envelope is produced by the VM, including a SHA-256 receipt over reviewer identity, signing time, and the approval verdict. Forging the envelope by constructing a dict literal does not yield valid signatures, so downstream consumers can verify provenance.
- Dual-control quorum requires distinct principals. The runtime
deduplicates reviewer IDs before counting approvals; replaying a
single signer's approval cannot satisfy
nofm. - Audit log records every decision. Each request, response, approval, denial, and timeout is appended to the event log on a topic determined by the primitive.
- Replay is deterministic. Given the same recorded approval outcomes, a replay of a script produces the same envelope (including the same signatures), enabling reproducible audits.
Primitives
ask_user<T>(prompt: string, options?: {schema?: Schema<T>, timeout?: duration, default?: T}) -> T
Pause the current dispatch until the host returns a typed response.
- If
schemais present, the returned value must satisfy it. - If
defaultis present and no schema is supplied, Harn coerces the host response toward the default's type when possible. timeoutdefaults to 24 hours.- If the wait times out, Harn returns
defaultwhen present; otherwise it throwsHumanTimeoutError. - Event log:
- request:
hitl.question_asked - response:
hitl.response_received - timeout:
hitl.timeout
- request:
type Choice = {
environment: "staging" | "prod",
}
let choice: Choice = ask_user(
"Where should this deploy?",
{schema: schema_of(Choice)},
)
request_approval(...) -> ApprovalRecord
request_approval(
action: string,
options?: ApprovalRequestOptions,
) -> ApprovalRecord
type ApprovalRequestOptions = {
detail?: any,
args?: any,
quorum?: int,
reviewers?: list<string>,
deadline?: duration,
principal?: string,
evidence_refs?: list<dict>,
undo_metadata?: dict,
capabilities_requested?: list<string>,
}
Emit an approval request and wait for a quorum of approving reviewers.
quorumdefaults to1.deadlinedefaults to 24 hours.- If
reviewersis omitted, any authorized reviewer may approve. argsis the canonical host-rendered argument payload. When omitted, Harn usesdetailfor compatibility with older scripts.principaldefaults to the current dispatch agent or session.evidence_refs,undo_metadata, andcapabilities_requestedare forwarded unchanged for hosts that render provenance, undo, or capability UX.- Denial raises
ApprovalDeniedError, which scripts can catch withtry. - Event log:
- request:
hitl.approval_requested - responses:
hitl.response_received - approved:
hitl.approval_approved - denied:
hitl.approval_denied - timeout:
hitl.timeout
- request:
ApprovalRecord is the shared shape:
type ApprovalRecord = {
approved: bool,
reviewers: list<string>,
approved_at: string,
reason: string | nil,
signatures: list<ApprovalSignature>,
}
type ApprovalSignature = {
reviewer: string,
signed_at: string,
signature: string,
}
Each counted approval contributes one signature receipt. Hosts may provide a signature directly; otherwise the VM records a deterministic receipt signature over the request id, reviewer, approval decision, reason, and signed timestamp.
ApprovalRequest is the canonical host-rendered request shape used by stdlib
HITL, bridge permission prompts, and external approval inboxes:
type ApprovalRequest = {
id: string,
action: string,
args: any,
principal: string,
requested_at: string,
deadline: string | nil,
approvers_required: int,
evidence_refs: list<dict>,
undo_metadata: dict | nil,
capabilities_requested: list<string>,
}
For stdlib HITL events, the durable request envelope stores this under
payload.approval_request and also mirrors the same fields at payload.id,
payload.action, payload.args, and so on. Legacy detail, quorum,
reviewers, and deadline_ms fields remain present so older hosts continue to
render and resolve existing flows.
dual_control<T>(n: int, m: int, action: fn() -> T, approvers?: list<string>) -> T
Run a closure only after n approvals out of m named approvers.
- Typical destructive-operation pattern:
dual_control(2, 3, { -> ... }, ["alice", "bob", "carol"]) - The closure does not run until quorum is satisfied.
- Denial raises
ApprovalDeniedError. - Event log:
- request:
hitl.dual_control_requested - responses:
hitl.response_received - approved:
hitl.dual_control_approved - denied:
hitl.dual_control_denied - executed:
hitl.dual_control_executed - timeout:
hitl.timeout
- request:
escalate_to(role: string, reason: string) -> EscalationHandle
Raise the current dispatch to a higher-trust role and block until the host accepts the escalation.
- The request is persisted before the dispatch pauses.
- The request payload includes the active capability policy when one is installed, so hosts can route or resolve the requested role against the same capability ceiling the VM is enforcing.
- The host resolves it by appending an acceptance event.
- If nobody accepts it, the dispatch remains paused until a host or operator resumes it explicitly.
- Event log:
- request:
hitl.escalation_issued - acceptance:
hitl.escalation_accepted
- request:
EscalationHandle is the shared shape:
type EscalationHandle = {
request_id: string,
role: string,
reason: string,
trace_id: string,
status: string,
accepted_at: string | nil,
reviewer: string | nil,
}
hitl_pending(filters?: HitlPendingFilters) -> list<HitlPendingRequest>
Read the active event log and return pending HITL requests as typed rows.
- Returns
[]when no event log is attached. - Reads and merges
hitl.questions,hitl.approvals,hitl.dual_control, andhitl.escalations. - Filters are all optional:
since/until: inclusive RFC3339 timestamp boundskinds: subset of"question" | "approval" | "dual_control" | "escalation"agent: exact agent id matchlimit: defaults to 500, capped at 5000
- Results are sorted newest first and omit requests that already reached a terminal HITL event.
HitlPendingRequest is the shared row shape:
type HitlPendingRequest = {
request_id: string,
request_kind: HitlRequestKind,
agent: string,
prompt: string,
trace_id: string,
timestamp: string,
approvers: list<string>,
metadata: dict,
}
Event topics
HITL records are written to dedicated durable topics:
hitl.questionshitl.approvalshitl.dual_controlhitl.escalations
These append through the normal event-log path, so they get the same crash-safety guarantees as trigger dispatch records.
Host contract
When a builtin opens a HITL wait, Harn emits a bridge notification:
harn.hitl.requested
For approval and dual-control requests, notification params are the durable
request envelope and include payload.approval_request with the canonical
ApprovalRequest object. Hosts should render that object instead of inventing
host-specific approval shapes.
Hosts resolve pending requests with the JSON-RPC method:
harn.hitl.respond
The response payload includes the request_id plus the relevant fields for
that request kind:
- questions:
answer - approvals / dual control:
approved,reviewer, optionalreason - escalations:
accepted,reviewer, optionalreason
ACP and MCP both expose harn.hitl.respond. The orchestrator CLI also exposes
manual escalation resume via harn orchestrator resume <request_id>.
Replay semantics
Replay is event-log-driven.
- Live dispatch: the host provides responses through
harn.hitl.respond. - Replay: the VM reads the previously recorded
hitl.response_receivedorhitl.escalation_acceptedevents instead of consulting a live host.
This makes trigger_replay(...) and harn trigger replay <event-id> replay-safe
for HITL flows as long as the original run recorded the HITL response events.
Approval reviewer identities, signed timestamps, and receipt signatures are
reused from those events during replay.
Patterns
Catch denials explicitly:
let result = try {
request_approval("deploy production", {quorum: 2, reviewers: ["alice", "bob"]})
}
if is_err(result) && unwrap_err(result).name == "ApprovalDeniedError" {
log("deployment denied")
}
Gate a destructive step behind dual control:
let deleted = dual_control(2, 3, {
delete_file("prod.db")
return true
}, ["alice", "bob", "carol"])