Some tools should not run without a human’s sign-off. Mark a tool as requiring approval and the run pauses before executing it: you get the pending calls back, record approve/reject decisions, and resume — in the same process or, via serialization, in a completely different one.
deleteRepo := agents.NewTool("delete_repo", "Permanently delete a repository.",
func(ctx context.Context, tc *agents.ToolContext, args deleteArgs) (string, error) {
return doDelete(args.Name)
})
deleteRepo.NeedsApproval = true
Or decide per call from the arguments:
deleteRepo.NeedsApprovalFunc = func(ctx context.Context, rc *agents.RunContext, argsJSON, callID string) (bool, error) {
return strings.Contains(argsJSON, `"prod"`), nil // only prod deletions need approval
}
When the model requests an approval-gated tool, Run returns without executing anything from that turn — so nothing runs twice after resumption:
res, err := agents.RunSync(ctx, agent, "delete the prod repo", opts)
if err != nil {
log.Fatal(err)
}
for len(res.Interruptions) > 0 {
for _, item := range res.Interruptions {
fmt.Printf("approve %s(%s)? ", item.ToolName, item.Arguments)
if askHuman() {
res.State.Approve(item, false)
} else {
res.State.Reject(item, false, "denied by operator")
}
}
res, err = agents.ResumeRunSync(ctx, res.State, opts)
if err != nil {
log.Fatal(err)
}
}
fmt.Println(res.FinalOutputString())
RunResult.Interruptions lists the pending calls (ToolName, CallID, raw Arguments).RunResult.State is the resumable *RunState; record decisions on it with Approve / Reject.Reject’s message (default: "Tool execution was not approved.") is sent to the model as the tool output so it can adapt.always=true to Approve/Reject to apply the decision to every future call of that tool in the run.Interruptions/State off the *RunCompletedEvent’s result, and resume with ResumeRun (a stream) or ResumeRunSync (the result alone). The resumed stream does not re-emit the paused turn’s own items — it picks up with the approved tools’ outputs and every later turn.NeedsApprovalFunc for it — the checker’s side effects and errors cannot re-fire for an already-resolved call.By default a tool’s input guardrails run only after approval, right before execution. RunOptions.Exec.PreApprovalToolInputGuardrails also runs them before the approval interruption is surfaced:
res, err := agents.RunSync(ctx, agent, input, agents.RunOptions{
Exec: agents.ExecOptions{PreApprovalToolInputGuardrails: true},
// ...
})
If a guardrail rejects the call, its message is returned to the model as the tool output — no approval request is emitted and the tool never runs, sparing the human a pointless round-trip. Calls that pass still re-run the same guardrails immediately before execution after approval, so time-sensitive checks are revalidated on resume.
RunState serializes to JSON, so the approval can happen hours later in another process (a ticket queue, a Slack button, …):
// Process A: pause and persist
data, _ := json.Marshal(res.State)
store.Save(runID, data)
// Process B: restore, decide, resume
state, err := agents.RunStateFromJSON(data, map[string]*agents.Agent{
"assistant": assistant, // every agent that participated, by name
})
if err != nil { … }
state.Approve(state.Interruptions[0], false)
res, err := agents.ResumeRunSync(ctx, state, opts)
Because Go functions don’t serialize, RunStateFromJSON needs a registry mapping agent names back to your *Agent values. The format round-trips within this SDK only; it is not an interchange format with any other agents SDK.
The state round-trips whole: input queued through RunControl before the pause, deferred tools already disclosed to the model, and the server-conversation cursor (UsePreviousResponseID / ConversationID deltas) all survive the JSON trip, so a cross-process resume behaves exactly like an in-process one.
The state also carries the original run’s MaxTurns, so a run started with a raised budget (say 20) that pauses on turn 12 resumes under the same budget even in a fresh process — ResumeRun uses opts.MaxTurns when set, else the serialized budget, else the default. Note that on a resumed result, NewItems items carry their replayed input form rather than the original model item: Kind and Display() survive, Raw is nil.
The registry holds your *Agent values, so an agent that was transformed
at build time must be rebuilt the same way — middleware.Plan.Apply, tool
injection, whatever produced the agent the paused run was using. What a
rebuild does NOT restore is the transform’s own progress: Plan.Apply returns
a fresh, locked PlanPhase, so a run that paused after its plan was
approved (an exec_command approval, say) would resume without its write
tools. Re-arm it from your own record before resuming — the durable answer to
“is this run past its plan phase” is whatever your PlanPhase.OnUnlock hook
wrote, and nothing else.
RunState.Extra is where such state rides the pause: a
map[string]json.RawMessage the SDK carries verbatim (never reads, never
writes), so what you must remember lives inside the state instead of in a
side channel next to it. Prefix your keys ("plan:phase") to avoid
collisions.
// Pausing: ride the phase state along.
res.State.Extra = map[string]json.RawMessage{
"plan:unlocked": json.RawMessage(fmt.Sprintf("%t", phase.Executing())),
}
data, _ := json.Marshal(res.State)
// Resuming: rebuild, then re-arm before ResumeRun.
agent, phase := middleware.Plan{}.Apply(baseAgent)
state, _ := agents.RunStateFromJSON(data, map[string]*agents.Agent{agent.Name: agent})
if string(state.Extra["plan:unlocked"]) == "true" {
_ = phase.Unlock()
}
Extra covers pause→resume, not crashes: a value lands there only when a
pause serializes the state. A fact that must survive a crash mid-run — the
moment the plan unlocked — needs your own durable write at that moment, which
is exactly what PlanPhase.OnUnlock is for; the two records answer different
questions.
When the run uses a Session, the user input and every completed turn are already persisted by the time the run pauses; only the pending, output-less tool calls are held back (they would break replay) and saved together with their outputs once the resumed run continues. Pass the same Session in ResumeRun’s options.