Skip to content

Agent interrupts

Interrupts let a tool pause execution and return a tool request to the client. The client can approve, reject, provide missing data, refresh credentials, or ask the user a question, then resume the turn.

Use interrupts when the model can decide that outside input is needed but the tool should not proceed automatically. Common cases include human approval, missing user choices, risky operations, payments, external auth, and actions that need a fresh environment check.

Interrupts are one feature with two transports. The tool side is the same as on Interrupts: genkit.DefineResumableTool, tool.Interrupt, ai.InterruptAs, and the claim through the tool’s Interrupted method, whose Restart and Respond verbs build the resume parts. Only the delivery differs. A plain generate call takes the resume parts through ai.WithResume; an agent takes them through conn.SendResume or AgentInput.Resume, which the agent forwards to the same generate resume path.

Define a resumable tool with genkit.DefineResumableTool. Its third type parameter is the resume payload: the function receives it as a pointer that is nil on the first call, and that holds the client’s answer when the turn is resumed. The tool pauses by returning tool.Interrupt(ctx, data); on resume it runs again from the top with the payload set. Keep approval and execution logic in one tool, and require the client to explicitly resume the turn.

import (
"context"
"errors"
"fmt"
"github.com/firebase/genkit/go/ai"
aix "github.com/firebase/genkit/go/ai/exp"
"github.com/firebase/genkit/go/ai/tool"
"github.com/firebase/genkit/go/genkit"
)
type TransferInput struct {
To string `json:"to"`
Amount float64 `json:"amount"`
}
type TransferOutput struct {
ConfirmationID string `json:"confirmationId"`
}
// TransferInterrupt is what the tool says when it pauses. The input already
// says what the model asked for, so this holds only the tool's reason.
type TransferInterrupt struct {
Reason string `json:"reason"`
}
// Confirmation is the resume payload the client sends back to approve or
// reject the paused transfer.
type Confirmation struct {
Approved bool `json:"approved"`
}
transferMoney := genkit.DefineResumableTool(g, "transferMoney",
"Transfer money after user approval.",
func(ctx context.Context, input TransferInput, confirm *Confirmation) (TransferOutput, error) {
if confirm == nil {
return TransferOutput{}, tool.Interrupt(ctx, TransferInterrupt{
Reason: "Approval is required before transferring money.",
})
}
if !confirm.Approved {
return TransferOutput{}, errors.New("transfer rejected by user")
}
return TransferOutput{ConfirmationID: "txn-123"}, nil
},
)

DefineResumableTool returns *ai.ResumableToolAction[In, Out, Res], so the tool above has type *ai.ResumableToolAction[TransferInput, TransferOutput, Confirmation]. Write the type out when the tool must outlive the enclosing function:

type App struct {
Transfer *ai.ResumableToolAction[TransferInput, TransferOutput, Confirmation]
}

The banker agent in the basic-agents sample runs this end to end: its tool interrupts with a discriminated payload, and the CLI asks the user and resumes the turn.

Interrupts are model tool request parts that are paused, which part.IsInterrupt() reports.

var interrupts []*ai.Part
for chunk, err := range conn.Receive() {
if err != nil {
return fmt.Errorf("stream turn: %w", err)
}
if chunk.ModelChunk != nil {
interrupts = append(interrupts, chunk.ModelChunk.Interrupts()...)
}
if chunk.TurnEnd != nil {
break
}
}

Claim each part with the tool that raised it. The claim decodes the input to the tool’s input type, and ai.InterruptAs[T] decodes the data the tool paused with:

call, ok := transferMoney.Interrupted(interrupts[0])
if ok {
meta, _ := ai.InterruptAs[TransferInterrupt](call.Part)
fmt.Printf("Approve transfer of %.2f to %s? (%s)\n", call.Input.Amount, call.Input.To, meta.Reason)
}

Interrupted reports false for an interrupt of another tool and for a hold that middleware such as ToolApproval raised. Claim holds as shown in Answer middleware holds.

(*ai.ModelResponse).Interrupts() and chunk.ModelChunk.Interrupts() cover the live paths. From a stored snapshot there is no response object, so filter the messages yourself: take the newest message whose Role is ai.RoleModel and keep the parts for which part.IsInterrupt() reports true. *ai.Part implements MarshalJSON and UnmarshalJSON, so an interrupt part can be persisted in your own queue, reloaded, and claimed as is.

// BankState is the agent's custom state type.
type BankState struct {
Balance float64 `json:"balance,omitempty"`
}
func pendingInterrupts(snap *aix.SessionSnapshot[BankState]) []*ai.Part {
if snap == nil || snap.State == nil {
// State is nil on a pending snapshot: detached work is still running.
return nil
}
for i := len(snap.State.Messages) - 1; i >= 0; i-- {
msg := snap.State.Messages[i]
if msg.Role != ai.RoleModel {
continue
}
var pending []*ai.Part
for _, part := range msg.Content {
if part.IsInterrupt() {
pending = append(pending, part)
}
}
return pending
}
return nil
}

Stop at the first model message. Resume validation only searches the most recent model response, so parts from an earlier turn are rejected as stale.

Build resume parts from the claimed call, then send them with conn.SendResume. Use Restart to re-execute the tool, delivering the client’s typed answer to its resume parameter. The tool runs again from the top, this time with a non-nil payload. The claim already checked the part, so the verbs cannot fail.

if err := conn.SendResume(&aix.ToolResume{
Restart: []*ai.Part{call.Restart(Confirmation{Approved: true})},
}); err != nil {
return fmt.Errorf("send resume: %w", err)
}

Use Respond when the result should be the tool output and the tool should not run again, such as supplying a precomputed result:

if err := conn.SendResume(&aix.ToolResume{
Respond: []*ai.Part{call.Respond(TransferOutput{ConfirmationID: "manual-approval"})},
}); err != nil {
return fmt.Errorf("send resume: %w", err)
}

call.RestartWithInput(input, resume) re-executes the tool with revised input, and goes in Restart too.

A resumed turn can interrupt again. Streaming clients should handle interrupts in a loop: receive until TurnEnd, resolve any interrupts, send resume, then receive the continuation.

The same resumable-tool API works outside an agent. The basic-tool-interrupts sample runs the pause-and-resume cycle across two ordinary generate turns instead.

An AgentConnection is a convenience, not a requirement. aix.AgentInput carries the same payload on its Resume field, and Agent.Run accepts invocation options alongside a full AgentInput, so one call resumes a session from any process:

out, err := agent.Run(ctx, &aix.AgentInput{
Resume: &aix.ToolResume{Restart: []*ai.Part{call.Restart(Confirmation{Approved: true})}},
}, aix.WithSessionID[BankState](sessionID))

An AgentInput carrying only Resume, with no Message, is valid. The two fields of aix.ToolResume match the verbs:

FieldTypeHolds parts fromEffect
Restart[]*ai.Partcall.Restart, call.RestartWithInputRe-runs the tool with the typed resume payload attached.
Respond[]*ai.Partcall.RespondSupplies the tool output directly; the tool does not run again.

Both may appear in one payload. Over HTTP the same object rides in data.resume.

The process that paused the turn does not need to be the one that answers it. Read the session’s latest snapshot, find the pending interrupt parts, claim them with the tool, then run:

func approveTransfer(
ctx context.Context,
agent *aix.Agent[BankState],
transferMoney *ai.ResumableToolAction[TransferInput, TransferOutput, Confirmation],
sessionID string,
) error {
snap, err := agent.GetLatestSnapshot(ctx, sessionID)
if err != nil {
return fmt.Errorf("read latest snapshot: %w", err)
}
if snap.FinishReason != aix.AgentFinishReasonInterrupted {
return fmt.Errorf("session %q is not waiting on an interrupt", sessionID)
}
var restarts []*ai.Part
for _, part := range pendingInterrupts(snap) {
call, ok := transferMoney.Interrupted(part)
if !ok {
continue
}
fmt.Printf("Approve transfer of %.2f to %s?\n", call.Input.Amount, call.Input.To)
restarts = append(restarts, call.Restart(Confirmation{Approved: true}))
}
if len(restarts) == 0 {
return errors.New("no pending interrupts for this tool")
}
out, err := agent.Run(ctx, &aix.AgentInput{
Resume: &aix.ToolResume{Restart: restarts},
}, aix.WithSessionID[BankState](sessionID))
if err != nil {
return fmt.Errorf("resume session: %w", err)
}
fmt.Println(out.Message.Text())
return nil
}

The server re-validates the payload with aix.ValidateResumeAgainstHistory before it reaches the model, so a forged restart is rejected.

An interrupted turn is an ordinary completed turn. With a session store it writes a snapshot whose Status is completed and whose FinishReason is interrupted, so it is a resume point like any settled turn; see the status table on Background execution. Read FinishReason, not Status, to tell an interrupt apart from a plain finished turn.

There is no expiry on a paused turn. The pending and expired statuses apply only to detached background work. The only constraint is that a resume must target tool requests in the snapshot’s most recent model message, so answer the interrupt before sending an unrelated user message on the same session.

The runtime validates a resume payload against session history before it reaches the model. A respond entry must match an interrupted tool request by name and ref. A restart entry must match the original request and carry its input. A restart built with RestartWithInput carries the revised input and records the original one it replaced, and it is the recorded original that must match. This protects server tools from forged client payloads that change the input of the call the model made.

Only the most recent model response is searched. An entry naming a tool request from an earlier turn is rejected as stale, separately from one naming a request that never existed. Refs are only unique within a single model response, so searching further back would let a caller validate a restart whose input was forged from an already-settled call. A client that accumulates resume entries across turns and resends them all must send only the entries for the pending response.

genkitx.DefineAgent and genkitx.DefinePromptAgent run this check for you. A custom agent that accepts AgentInput.Resume from untrusted callers should run it itself before forwarding the payload:

if input.Resume != nil {
if err := aix.ValidateResumeAgainstHistory(input.Resume, sess.Messages()); err != nil {
return nil, err
}
}

The check accepts a revised input. A server that must not let callers revise a tool’s input rejects restart parts whose Restart.OriginalInput is set, as shown in Resuming from an untrusted caller.