Skip to content

Sessions and state

In Genkit, agent state includes message history, custom application state, artifacts, session identity, and snapshot lineage. Choose the state strategy before building the client because it determines who owns continuity between turns.

Genkit agents keep continuity in one of two ownership models.

Server-managed state uses aix.WithSessionStore(store). The server persists snapshots and callers continue with aix.WithSessionID or aix.WithSnapshotID. Choose this for durable conversations, background execution, snapshot reads, branching, or clients that should not hold conversation history.

Client-managed state omits a store. The caller receives AgentOutput.State and sends it back with aix.WithState. Choose this when your service already stores session data, when you need stateless Genkit workers, or when another system controls encryption and retention.

Server-managed state is the default recommendation for user-facing conversational apps. Client-managed state is useful when you need tighter control over where state is stored and how it moves between services.

go/samples/basic-agents-server serves both models side by side: a chat agent with a store, which answers with a session ID to send back, and a statelessChat agent without one, which answers with the whole state for the client to hold.

Session state contains messages, typed custom state, artifacts, and the framework-owned session ID:

import (
"github.com/firebase/genkit/go/ai"
aix "github.com/firebase/genkit/go/ai/exp"
)
type SessionState[State any] struct {
Artifacts []*aix.Artifact `json:"artifacts,omitempty"`
Custom State `json:"custom,omitempty"`
Messages []*ai.Message `json:"messages,omitempty"`
SessionID string `json:"sessionId,omitempty"`
}
  • Custom is your typed application state. Use it for compact data the agent needs across turns, such as workflow status, selected records, preferences, task state, or progress.
  • Messages is conversation history.
  • Artifacts contains generated outputs that the app or user may inspect independently.

Custom and Artifacts both live in session state, so choose by role:

  • Use custom state for the compact control and UI data that drives the next turn, such as workflow status, selected records, preferences, task state, or progress. It rides in every snapshot, so keep it small.
  • Use artifacts for generated outputs the user may inspect, reuse, or version independently, such as reports, files, patches, or documents.

Do not put large generated documents into Custom just because they serialize to JSON; make them artifacts.

Tools and custom agents update typed custom state through the active session. In a custom agent, call sess.UpdateCustom:

sess.UpdateCustom(func(state TravelState) TravelState {
state.Status = "Checking weather"
state.LastCity = city
return state
})

A tool reaches the same session through its context with aix.SessionFromContext[State]. The updater takes and returns the state as its own Go type, so a mistyped field is a compile error rather than a lost key.

SessionFromContext[State] returns nil in two cases: there is no session in context, and the active session’s state type is not State. A tool shared between agents with different state types therefore gets nil from the mismatching agent, with no error. Fail closed on nil. Return a status.ErrFailedPrecondition (or status.ErrPermissionDenied when the state carries scoping) rather than treating nil as an empty identity, or the tool silently drops whatever it derived from state.

import (
aix "github.com/firebase/genkit/go/ai/exp"
"github.com/firebase/genkit/go/core/status"
genkitx "github.com/firebase/genkit/go/genkit/exp"
)
// OrderState is the agent's custom state type.
type OrderState struct {
Drinks []string `json:"drinks,omitempty"`
}
addToOrder := genkitx.DefineTool(g, "addToOrder",
"Records one drink the customer ordered.",
func(ctx context.Context, in struct {
Drink string `json:"drink"`
}) (string, error) {
sess := aix.SessionFromContext[OrderState](ctx)
if sess == nil {
return "", status.Errorf(status.ErrFailedPrecondition,
"addToOrder must be called inside a session")
}
sess.UpdateCustom(func(order OrderState) OrderState {
order.Drinks = append(order.Drinks, in.Drink)
return order
})
return "Added " + in.Drink + " to the order.", nil
})

What a tool writes is visible to the next turn’s prompt render, so an agent can turn its own state back into instructions. ai.WithSystemFn runs once per turn and its result is used verbatim, which is what lets the wording branch on the state:

barista := genkitx.DefineAgent(g, "barista",
aix.InlinePrompt{
ai.WithModelName("googleai/gemini-flash-latest"),
ai.WithTools(addToOrder),
ai.WithSystemFn(func(ctx context.Context, _ any) (string, error) {
sess := aix.SessionFromContext[OrderState](ctx)
if sess == nil {
return "", status.Errorf(status.ErrFailedPrecondition,
"barista prompt rendered outside a session")
}
order := sess.Custom()
if len(order.Drinks) == 0 {
return "You are a brisk barista. Take the order one drink at a time.", nil
}
return fmt.Sprintf("You are a brisk barista. Ordered so far: %s.",
strings.Join(order.Drinks, ", ")), nil
}),
},
aix.WithSessionStore(store),
)

The barista.go file of go/samples/basic-agents runs this pair end to end. Note the omitempty on the state’s slice field: a nil Go slice marshals to null, which does not satisfy the array schema inferred from the type.

The runtime streams custom patches as state changes. AgentConnection.Receive applies those patches, and AgentConnection.Custom() returns the current custom state observed by the connection.

WithSessionStore switches the agent to server-managed state. The store must support snapshot reads and writes. Background detach and abort support also require the store to implement SnapshotSubscriber.

import (
"github.com/firebase/genkit/go/ai/exp/localstore"
genkitx "github.com/firebase/genkit/go/genkit/exp"
)
store, err := localstore.NewFileSessionStore[TravelState]("./.genkit/snapshots/travel")
if err != nil {
// Fails if the snapshot directory cannot be created or is not writable.
log.Fatalf("open travel store: %v", err)
}
agent := genkitx.DefineAgent(g, "travel",
aix.InlinePrompt{
ai.WithModelName("googleai/gemini-flash-latest"),
ai.WithSystem("You are a travel assistant."),
},
aix.WithSessionStore(store),
)

FileSessionStore persists snapshots as JSON files and is safe for concurrent use. It can also notify status subscribers when snapshots change, which enables abort handling and efficient wait loops.

For store options and custom store implementation guidance, see Session stores.

Use the typed agent methods when reading snapshots locally:

snap, err := agent.GetSnapshot(ctx, snapshotID)
latest, err := agent.GetLatestSnapshot(ctx, sessionID)
settled, err := agent.WaitForSnapshot(ctx, snapshotID) // blocks until the row settles
meta, err := agent.GetSnapshot(ctx, snapshotID, aix.WithMetadataOnly()) // status and timestamps, State nil

These methods apply WithStateTransform and read-time shaping, such as reporting a detached run whose heartbeat went stale as expired. Reading agent.Store() directly returns raw, untransformed state. aix.WithMetadataOnly() skips the conversation entirely on a store that implements aix.SnapshotMetadataReader, which the bundled stores and the Firestore store do.

StatusMeaning
pendingA detached invocation is still running.
abortingAn abort stopped the work and the worker is saving what it finished.
completedThe turn finished. The snapshot captures settled state and can be resumed.
failedThe turn broke. The snapshot holds the tool rounds it completed and the structured error, and can be resumed.
abortedThe caller stopped the run. The snapshot holds the turns that finished, and can be resumed.
expiredA pending or aborting snapshot’s heartbeat is stale. This status is computed on read and is not written back to the store.

A turn that finishes writes completed. A turn that breaks writes failed, and a run its caller stops, by cancelling the context, letting a deadline expire, or aborting a detached run, writes aborted. Both keep the turns that ended at a seam and drop the one that did not, so both are resume points, and the output names them in SnapshotID. See Agent error handling.

Status is the persistence lifecycle, not the outcome. A turn that ends on an interrupt still writes completed, because it is resumable. To detect an interrupt, read snap.FinishReason, which is a separate field:

paused := snap.Status == aix.SnapshotStatusCompleted &&
snap.FinishReason == aix.AgentFinishReasonInterrupted

FinishReason carries the semantic outcome of the turn or invocation the snapshot captured: aix.AgentFinishReasonStop, AgentFinishReasonInterrupted, AgentFinishReasonFailed, AgentFinishReasonAborted, and the other values on aix.AgentFinishReason. See Agent interrupts for what a paused turn allows next.

Use WithSessionID when the caller tracks a conversation:

out, err := agent.RunText(ctx, "What about Paris?",
aix.WithSessionID[TravelState](previous.SessionID),
)

Use WithSnapshotID to branch:

rainPlan, err := agent.RunText(ctx, "Assume it rains.",
aix.WithSnapshotID[TravelState](base.SnapshotID),
)

Use WithState for client-managed agents:

out, err := agent.RunText(ctx, "Add buy milk.",
aix.WithState(previous.State),
)

An empty session ID is rejected. WithState cannot be combined with snapshot or session options.

You may also choose the session ID yourself. If aix.WithSessionID(id) resolves no existing snapshot, the runtime starts a fresh conversation under that ID and stamps it on every snapshot it persists, so a client can mint a UUID up front instead of waiting for a server-issued ID. SessionID is framework-owned only in the sense that the framework mints one when you supply none, and that for server-managed agents the snapshot row’s ID is canonical.

Custom state is persisted as plain JSON and decoded with encoding/json. That gives three rules:

  • Adding a field is safe. Old snapshots load it as the Go zero value.
  • Removing or renaming a field is safe to load, but drops the old data silently. Nothing rejects the unknown key.
  • Changing a field’s JSON type is not safe. The decode fails, the store read returns an error, and the resume fails with it.

Keep changes additive. Keep an old field readable with its original json tag through a migration window, and carry your own version field inside Custom when you need to branch on shape. There is no schema version on the snapshot envelope.

WithStateTransform redacts or reshapes session state on the way out to clients. It applies to snapshot reads and client-managed output, not to persisted raw state or the agent function’s internal view.

agent := genkitx.DefineAgent(g, "support",
aix.InlinePrompt{
ai.WithSystem("Summarize support cases."),
},
aix.WithSessionStore(store),
aix.WithStateTransform(func(ctx context.Context, state *aix.SessionState[SupportState]) (*aix.SessionState[SupportState], error) {
state.Custom.InternalNotes = ""
return state, nil
}),
)

aix.WithStreamTransform[State](fn) runs on every streamed chunk at the wire boundary. It takes a func(ctx context.Context, chunk *aix.AgentStreamChunk) (*aix.AgentStreamChunk, error). A chunk carries no state type, so State cannot be inferred and must be written out.

aix.WithStreamTransform[SupportState](func(ctx context.Context, chunk *aix.AgentStreamChunk) (*aix.AgentStreamChunk, error) {
if chunk.Artifact != nil && chunk.Artifact.Name == "internal-notes" {
// Drop the chunk from the wire; the session still records the artifact.
return nil, nil
}
return chunk, nil
})

AgentStreamChunk has four fields, and more than one can be set on a single chunk:

FieldTypeContents
ModelChunk*ai.ModelResponseChunkGeneration tokens from the model.
Artifact*aix.ArtifactA newly produced artifact.
CustomPatchaix.JSONPatchAn RFC 6902 delta to custom state.
TurnEnd*aix.TurnEndThe turn-end signal, including the new snapshot ID.

The chunk is a fresh deep copy the transform owns, so mutating it in place is safe. Returning nil drops the chunk from the wire only: side effects and the final AgentOutput keep the data. Never drop a chunk whose TurnEnd is set, because clients pace the conversation on that signal; reshape it instead. Returning an error fails the whole invocation, which is the fail-closed behavior you want when a chunk cannot be shaped safely.

Prefer WithStateTransform for custom state redaction. The runtime applies it before diffing, so the patch stream stays consistent; rewriting CustomPatch in a stream transform desyncs clients that rebuild custom state from the patch sequence.

Prompt-backed and custom agents can update state through the active session. Custom patches are streamed automatically when custom state changes. AgentConnection.Receive applies those patches and AgentConnection.Custom() returns the live custom state observed so far.

An artifact is a named collection of parts:

type Artifact struct {
Metadata map[string]any `json:"metadata,omitempty"`
Name string `json:"name,omitempty"`
Parts []*ai.Part `json:"parts"`
}

Responder.SendArtifact takes a pointer. It both streams the artifact and records it in the session, so the artifact is available in the final output or snapshot:

resp.SendArtifact(&aix.Artifact{
Name: "meal-plan",
Parts: []*ai.Part{ai.NewTextPart(draft)},
Metadata: map[string]any{"contentType": "text/markdown"},
})

Read them back from out.Artifacts on an *aix.AgentOutput[State], from snap.State.Artifacts on a snapshot, or from sess.Artifacts() inside a run. SendArtifact appends and does not deduplicate names, so two sends with the same Name produce two entries. Call sess.UpdateArtifacts when you want to replace one instead.