Skip to content

Approvals

Some calls shouldn't be decided by a rule alone: a $250 refund is fine if someone in finance agrees. When a step needs a person, the run suspends. Its state is saved, an approval request goes out, and the run waits. When an approver answers, the run continues exactly where it stopped, even in another process.

Ask for approval

Add a third rule, and say who approves:

harness.yaml (policy and approvals)
policy:
  rules:
    - id: refund-cap
      when: "tool.name == 'refund_payment' && args.amount_usd > 500"
      effect: deny
      reason: refunds over $500 go to the finance desk
    - id: no-contractor-refunds
      when: "tool.name == 'refund_payment' && 'contractor' in principal.roles"
      effect: deny
    - id: big-refunds
      when: "tool.name == 'refund_payment' && args.amount_usd > 100"
      effect: require_approval
      reason: refunds over $100 need a person from finance

approvals:
  approver_role: finance
  timeout: 24h

require_approval sends the call to a person. approvals.approver_role says whose job that is when the step that asked doesn't say, and timeout is how long the request stays open.

Suspend, then approve

main.py
import asyncio

import harnessweaver as hw


async def main() -> None:
    harness = hw.load("harness.yaml")
    result = await harness.run("orders", "Refund $250 on order A-1001.", principal="ann")
    print(result.status.value)
    request = result.approval
    if request is None:
        return

    print(f"waiting for {request.approver_role}: {request.reason}")
    if request.call is not None:
        print(f"tool call: {request.call.name} {request.call.arguments}")

    fiona = hw.Principal(id="fiona", roles=("finance",))
    result = await harness.approve(result.run_id, request.approval_id, "approve", approver=fiona)
    print(result.status.value)
    print(result.output)


if __name__ == "__main__":
    asyncio.run(main())
$ HARNESSWEAVER_ENV=offline uv run python main.py
awaiting_approval
waiting for finance: refunds over $100 need a person from finance
tool call: refund_payment {'order_id': 'A-1001', 'amount_usd': 250}
completed
Done: the refund is on its way to your card.

The first run returned with the status awaiting_approval and result.approval, an ApprovalRequest: the call that's waiting, the reason, the role that may approve, and when the request expires. Your app shows it to someone in finance. harness.approve records their answer, and the run picks up from the pending call: the refund runs, and the model writes its reply.

sequenceDiagram
    autonumber
    participant App
    participant H as Harness
    participant S as State store
    participant P as Approver
    App->>H: run("orders", "Refund $250 ...")
    H->>H: governor: approval needed
    H->>S: checkpoint the run
    H-->>App: RunResult(awaiting_approval, approval=...)
    App->>P: show the request
    P->>App: approve
    App->>H: approve(run_id, approval_id, "approve", approver=fiona)
    H->>H: check fiona's role, fire approval.resolved
    H->>H: resume from the checkpoint
    H-->>App: RunResult(completed)

Answering

await harness.approve(run_id, approval_id, decision, approver=who, edited_args=None, reason=None)
Decision Effect
approve The call goes ahead
edit The call goes ahead with edited_args, which pass through every governor check again
reject The run ends blocked, with the code approval_rejected

The harness checks that the approver holds the approver role, so fiona needs the finance role. An approval never overrides a deny: edited arguments that a rule denies are still blocked. An answer that arrives after the request expired ends the run as approvals.on_timeout says (escalate by default, or reject).

What asks for approval

Source Example
A policy rule with effect: require_approval The big-refunds rule above
A manifest's approval.when approval: { when: "args.amount_usd > 100", approver_role: finance }
tools.approval_by_risk { high: required, critical: required }: every high-risk call
A task's approvals.tools approvals: { tools: [refund_payment] }: always, in this task
The lethal trifecta, with tools.trifecta: require_approval Data could leave after untrusted content and private data entered the run
A guard with action: require_approval on the output A person checks the answer before it goes out
Your own middleware returning Suspend(reason, approver_role) Anything your code decides

The approver role comes from the step that asked, else the task's approvals.approver_role, else the spec's approvals.approver_role.

An approval covers one execution of the call: if the model asks for the same call again, a person is asked again. approvals.grant_scope: run makes it last the run instead (Approvals in depth).

Where requests go

approvals.use picks the channel that is told about each request, once it and the run's checkpoint are saved:

Channel Does
memory Keeps requests for your app to read (the default)
callback Calls your function with each ApprovalRequest
console Prints one line per request, for local development
webhook POSTs each request as JSON to a URL

Slack, e-mail and an approval page are planned. Providers has each channel's settings.

Only harness-driven runs can wait

harness.run and the runners suspend and resume. In a standalone session, a wrapped agent, or TypeScript, a request for approval escalates instead, because the harness can't re-enter your app's code later.

Waiting longer than a process lives

A run that waits for a person must outlive the process that started it. With the default in-memory state store it can't. The next chapter, State and replay, moves the state to a file so a run can be approved after a restart. Approvals in depth covers grants, how each runner resumes, and background runs.

Recap

  • A step that needs a person suspends the run (awaiting_approval), and result.approval says what's waiting.
  • harness.approve(...) answers it: approve, edit (re-checked from the start) or reject. The run then resumes where it stopped.
  • Approvals come from policy rules, manifests, risk levels, task settings, the trifecta, guards and your own middleware. None of them can override a deny.