Skip to content

Background execution

Background execution lets a client submit work, disconnect, and return later. It requires server-managed state because the server needs a snapshot to track progress, liveness, completion, failure, and cancellation.

Use background execution for work that may outlive the request or the browser tab, such as report generation, long research tasks, multi-step planning, or tool-heavy workflows. Keep normal foreground streaming for short turns where the user is actively waiting and foreground cancellation is enough.

Store support matters for background work. See Session stores for which stores support snapshot status changes and aborting detached work.

Background execution needs a server-managed agent whose store implements aix.SnapshotSubscriber, which is how an abort reaches the running work. The bundled in-memory and file stores and the Firestore store all do.

Choose background execution when the caller should get a snapshot ID back at once and let the agent keep working on the server. A command-line tool or a service that can hold the connection open is usually simpler with a streaming Connect call.

import (
"context"
"fmt"
"log"
"time"
"github.com/firebase/genkit/go/ai"
aix "github.com/firebase/genkit/go/ai/exp"
"github.com/firebase/genkit/go/ai/exp/localstore"
genkitx "github.com/firebase/genkit/go/genkit/exp"
)
store, err := localstore.NewFileSessionStore[ReportState]("./.genkit/snapshots/reports")
if err != nil {
// Fails if the snapshot directory cannot be created or is not writable.
log.Fatalf("open report store: %v", err)
}
agent := genkitx.DefineAgent(g, "report",
aix.InlinePrompt{
ai.WithModelName("googleai/gemini-flash-latest"),
ai.WithSystem("Create detailed research reports."),
},
aix.WithSessionStore(store),
)

The basic-agents-server sample walks the whole lifecycle over plain HTTP with curl: detach, wait, and abort.

RunDetached sends one input, returns once the server has accepted the background work, and hands back a *aix.DetachedTask[State]:

task, err := agent.RunDetached(ctx, &aix.AgentInput{
Message: ai.NewUserTextMessage("Write the quarterly report."),
})
if err != nil {
// The work never started: a rejected init, or a store that cannot detach.
return fmt.Errorf("start report: %w", err)
}
savePendingSnapshot(task.SnapshotID())

The task’s only state is its snapshot ID, so store that ID before the process that started the work goes away. agent.Task(snapshotID) rebuilds the same handle in any process, at any later time.

On a live AgentConnection, the same directive is an input with Detach: true, or conn.Detach() to leave a turn that is already under way:

conn, err := agent.Connect(ctx)
if err != nil {
return fmt.Errorf("connect to agent: %w", err)
}
if err := conn.Send(&aix.AgentInput{
Message: ai.NewUserTextMessage("Write the quarterly report."),
Detach: true,
}); err != nil {
return fmt.Errorf("send detached input: %w", err)
}
out, err := conn.Output()
if err != nil {
// The detached turn could not be started; a started one resolves in-band.
return fmt.Errorf("read detached output: %w", err)
}
task := agent.Task(out.SnapshotID) // out.FinishReason is aix.AgentFinishReasonDetached

A bare conn.Detach() starts no extra turn. To ride a final input on the detach, set Message alongside Detach as above.

The client stream is suppressed the moment the detach directive is read, so nothing the background turn produces afterwards reaches conn.Receive(). Session-level side effects still apply: an artifact sent through Responder.SendArtifact still lands in the final snapshot’s state, so agent code does not have to branch on detach.

The invocation context outlives the transport connection. Before a detach lands, a client disconnect cancels the work. After it lands, the context stays live and the turn keeps running; only an abort or the process exiting cancels it.

snap, err := task.Poll(ctx) // one read: where the run stands now
snap, err = task.Wait(ctx) // blocks until the run settles

Wait returns the settled snapshot whatever its outcome, failed, aborted, and expired included; a non-nil error means the wait itself could not proceed, such as an unknown snapshot or a cancelled context. Bound it with context.WithTimeout when the caller cannot block indefinitely:

waitCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
snap, err := task.Wait(waitCtx)
if err != nil {
return fmt.Errorf("wait for report: %w", err)
}
if snap.Status == aix.SnapshotStatusCompleted {
renderMessages(snap.State.Messages)
}

The same reads exist on the agent for a bare snapshot ID: agent.GetSnapshot(ctx, id), agent.GetLatestSnapshot(ctx, sessionID), and agent.WaitForSnapshot(ctx, id). The wait is push-driven where the store implements aix.SnapshotSubscriber, with a periodic re-read either way, because an expiry is not a write and no subscription can report one. Over HTTP it is one request, POST /agents/{name}/waitForSnapshot, that answers once the row settles, so a client in another language needs no polling loop either. See Serve agents over HTTP.

A status check does not need the conversation. Pass aix.WithMetadataOnly() to Poll, GetSnapshot, or GetLatestSnapshot and the returned snapshot carries the status, finish reason, parent, timestamps, and error with State left nil. A store that implements the optional aix.SnapshotMetadataReader, as the bundled stores and the Firestore store do, answers without loading the state at all; any other store is read in full and the state dropped.

snap, err := task.Poll(ctx, aix.WithMetadataOnly())
StatusMeaningResume point
pendingThe worker is running and refreshing the heartbeat.No
abortingAn abort has stopped the work; the worker is saving what it finished.Not yet
completedThe run settled. State holds the final state.Yes
failedA turn broke. Error holds the failure, State holds the turns that completed before it.Yes
abortedThe caller stopped the run. State holds the turns that finished.Yes
expiredThe heartbeat went stale, so the worker is presumed dead. Computed on read, never written.No

snap.Status.Terminal() reports whether a status is settled, which is every status except pending and aborting. Status is the persistence lifecycle, not the outcome: a turn that ended on an interrupt is completed and resumable, and you tell it apart by reading snap.FinishReason == aix.AgentFinishReasonInterrupted.

The basic-agents sample drives this from its CLI: /detach leaves a turn running, and returning to the agent later waits on the pending snapshot or stops it and resumes from the turns it finished.

status, err := task.Abort(ctx) // or agent.Abort(ctx, snapshotID)
if err != nil {
// The abort could not be attempted. A snapshot that had already settled
// is not an error: Abort returns its status and changes nothing.
return fmt.Errorf("abort report: %w", err)
}

An abort takes two writes. The first flips the pending row to aborting and cancels the work context, so Abort answers aborting for a run that was still going. The worker keeps heartbeating while it unwinds, then lands the second write: an aborted snapshot holding every turn that finished before the stop. Wait rides that window and returns the settled row.

An aborted snapshot is a resume point. The turn that was in flight is discarded whole, so the conversation ends at a turn seam: a tool that had already run inside it loses its response along with the rest of the round, and resuming calls it again. Send an input with no payload to run that turn again on the committed conversation, or a new message to change course:

snap, err := task.Wait(ctx)
if err != nil {
return fmt.Errorf("wait for abort: %w", err)
}
if snap.Status == aix.SnapshotStatusAborted {
resumed, err := agent.Run(ctx, &aix.AgentInput{},
aix.WithSnapshotID[ReportState](snap.SnapshotID))
// ...
}

A failed snapshot resumes the same way; see Agent error handling. Abort is a no-op on a snapshot that has already settled and returns its status. Client-managed agents return FAILED_PRECONDITION, because there is no server snapshot to cancel.

Detached work runs in the process that started it. It is not durable execution: a restart, crash, or scale-in orphans every pending snapshot, and no other instance can adopt one.

A running detached turn refreshes its snapshot’s heartbeat every 30 seconds, and keeps doing so for up to five minutes while it winds down after an abort. A pending or aborting snapshot whose heartbeat has not advanced for 60 seconds is reported as expired on the next read. expired is terminal and not resumable. The row’s ParentID names the last snapshot committed before the detach, so resume from that and send the work again.

On SIGTERM, abort the pending snapshots and wait for them to settle before exiting. Callers then find aborted rows they can resume, instead of waiting 60 seconds for expiry and losing the run.

Detach and durable streaming solve different problems. Detach keeps the work running and gives you a snapshot ID to wait on, but it does not replay the stream. Durable streaming replays the chunk transcript by streamId, but it does not keep work alive past the request. genkitx.Route.Handler accepts genkit.HandlerOptions, so you can apply both to the same agent route.

agent.Task(snapshotID) rebuilds a task from a stored ID, and the result is equivalent to what RunDetached returned: the snapshot is the whole record.

Code that knows the agent only by name, such as an orchestrator or a middleware, reaches it through an aix.AgentHandle, the untyped view of the same agent with custom state fixed to json.RawMessage:

h := genkitx.LookupAgent(g, "report") // nil when no such agent is registered
if h == nil {
return fmt.Errorf("agent %q is not registered", "report")
}
snap, err := h.Task(snapshotID).Wait(ctx)

agent.Handle() returns the same view for an agent value you hold. A handle offers every call the typed agent does, with state as raw JSON, and every read through it is shaped exactly as it would be for a remote client: the agent’s WithStateTransform applies, and a stale-heartbeat row reads as expired. See Run and stream agents for the handle’s full surface.