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 API is the same as on Interrupts: genkitx.DefineInterruptibleTool, tool.Interrupt, tool.InterruptAs, and the tool’s Resume/Respond builders. Only the delivery differs. A plain generate call takes the resume parts through ai.WithToolRestarts and ai.WithToolResponses; an agent takes them through conn.SendResume or AgentInput.Resume, which the agent forwards to the identical generate resume path.

Define an interruptible tool with genkitx.DefineInterruptibleTool. Its third parameter is a typed resume payload: nil on the first call, and populated with the client’s answer when the turn is resumed. The tool pauses by returning tool.Interrupt(metadata); 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/exp/tool"
genkitx "github.com/firebase/genkit/go/genkit/exp"
)
type TransferInput struct {
To string `json:"to"`
Amount float64 `json:"amount"`
}
type TransferOutput struct {
ConfirmationID string `json:"confirmationId"`
}
type TransferInterrupt struct {
To string `json:"to"`
Amount float64 `json:"amount"`
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 := genkitx.DefineInterruptibleTool(g, "transferMoney",
"Transfer money after user approval.",
func(ctx context.Context, input TransferInput, confirm *Confirmation) (TransferOutput, error) {
if confirm == nil {
return TransferOutput{}, tool.Interrupt(TransferInterrupt{
To: input.To,
Amount: input.Amount,
Reason: "Approval is required before transferring money.",
})
}
if !confirm.Approved {
return TransferOutput{}, errors.New("transfer rejected by user")
}
return TransferOutput{ConfirmationID: "txn-123"}, nil
},
)

DefineInterruptibleTool returns *aix.InterruptibleTool[In, Out, Resume], so the tool above has type *aix.InterruptibleTool[TransferInput, TransferOutput, Confirmation]. Write the type out when the tool must outlive the enclosing function:

type App struct {
Transfer *aix.InterruptibleTool[TransferInput, TransferOutput, Confirmation]
}

InterruptibleTool embeds aix.Tool[In, Out] and adds two resume-part builders, Resume(part *ai.Part, res Resume) (*ai.Part, error) and Respond(part *ai.Part, out Out) (*ai.Part, error).

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 with interrupt metadata.

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
}
}

Use tool.InterruptAs[T] to decode typed interrupt metadata:

meta, ok := tool.InterruptAs[TransferInterrupt](interrupts[0])
if ok {
fmt.Printf("Approve transfer to %s?", meta.To)
}

(*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 and reloaded verbatim.

// 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 tool, then send them with conn.SendResume. Use Resume 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.

part, err := transferMoney.Resume(interrupts[0], Confirmation{Approved: true})
if err != nil {
// Fails if the part is not this tool's interrupt or the payload type
// does not match the tool's resume parameter.
return fmt.Errorf("build resume part: %w", err)
}
if err := conn.SendResume(&aix.ToolResume{
Restart: []*ai.Part{part},
}); 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:

part, err := transferMoney.Respond(interrupts[0], TransferOutput{
ConfirmationID: "manual-approval",
})
if err != nil {
// Fails if the part is not this tool's interrupt or the output type
// does not match the tool's result.
return fmt.Errorf("build respond part: %w", err)
}
if err := conn.SendResume(&aix.ToolResume{
Respond: []*ai.Part{part},
}); err != nil {
return fmt.Errorf("send resume: %w", err)
}

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 interruptible-tool API works outside an agent. The basic-tool-interrupts-exp 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{part}},
}, aix.WithSessionID[BankState](sessionID))

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

FieldTypeEffect
Restart[]*ai.PartRe-runs the tool with the typed resume payload attached.
Respond[]*ai.PartSupplies 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, build the resume parts with the tool, then run:

func approveTransfer(
ctx context.Context,
agent *aix.Agent[BankState],
transferMoney *aix.InterruptibleTool[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) {
meta, ok := tool.InterruptAs[TransferInterrupt](part)
if !ok {
continue
}
fmt.Printf("Approve transfer of %.2f to %s?\n", meta.Amount, meta.To)
restart, err := transferMoney.Resume(part, Confirmation{Approved: true})
if err != nil {
return fmt.Errorf("build resume part: %w", err)
}
restarts = append(restarts, restart)
}
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 approver never sees the original request payload, and does not need to: 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 unmodified input. This protects server tools from forged client payloads.

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
}
}