Skip to content

Define agents

In Genkit, agents are actions with conversation state. A standard agent renders a prompt on each turn, places the conversation history, calls the model, streams chunks, updates state, and optionally persists a snapshot. A custom agent keeps that runtime shell, but replaces the prompt-backed loop with your own code.

This page covers defining the agent itself. For when to choose an agent over a plain flow, see Full-stack agents.

  • genkitx.DefineAgent keeps inline prompt configuration beside the agent wiring. Use it for most prompt-backed agents.
  • genkitx.DefinePromptAgent wraps a prompt that is already registered, including prompts loaded from Dotprompt files.
  • genkitx.DefineCustomAgent replaces the prompt loop with your own code, for a custom per-turn loop, direct session control, or multiple model calls.

All agents implement api.BidiAction, so transports and route helpers can serve them directly. Server-managed agents also expose typed snapshot helpers and companion actions.

DefineAgent registers a prompt-backed agent from an aix.InlinePrompt. The inline prompt is a list of prompt options.

import (
"context"
"fmt"
"log"
"github.com/firebase/genkit/go/ai"
aix "github.com/firebase/genkit/go/ai/exp"
"github.com/firebase/genkit/go/ai/exp/localstore"
"github.com/firebase/genkit/go/genkit"
genkitx "github.com/firebase/genkit/go/genkit/exp"
)
// TaskState is this agent's custom session state.
type TaskState struct {
Tasks []string `json:"tasks,omitempty"`
}

In the snippet below, g is the *genkit.Genkit that genkit.Init returned, and addTaskTool and toggleTaskTool are tools defined as shown in Tool calling.

store, err := localstore.NewFileSessionStore[TaskState]("./.genkit/snapshots/tasks")
if err != nil {
// Fails if the snapshot directory cannot be created or is not writable.
log.Fatalf("open task store: %v", err)
}
taskAgent := genkitx.DefineAgent(g, "taskAgent",
aix.InlinePrompt{
ai.WithModelName("googleai/gemini-flash-latest"),
ai.WithSystem("Manage a task list. Use tools when changing tasks."),
ai.WithTools(addTaskTool, toggleTaskTool),
},
aix.WithSessionStore(store),
aix.WithDescription[TaskState]("Task management assistant"),
)

genkitx.DefineAgent returns *aix.Agent[State]. The value type lives in github.com/firebase/genkit/go/ai/exp even though the constructor lives in github.com/firebase/genkit/go/genkit/exp, so write the aix type when you store the agent in a struct field or pass it between functions:

type App struct {
Tasks *aix.Agent[TaskState]
}

The agent’s custom state type is inferred from typed options such as WithSessionStore[TaskState], WithStateTransform[TaskState], or the explicit type argument on DefineAgent[TaskState]. An agent that passes no typed option needs the argument written out, as in DefineAgent[any].

The genkitx.Define* constructors all return aix types, so a helper that takes an agent, a tool, or a turn result names the type from github.com/firebase/genkit/go/ai/exp.

func DefineAgent[State any](g *genkit.Genkit, name string, prompt aix.InlinePrompt,
opts ...aix.AgentOption[State]) *aix.Agent[State]
func DefineTool[In, Out any](g *genkit.Genkit, name, description string,
fn aix.ToolFunc[In, Out], opts ...ai.ToolOption) *aix.Tool[In, Out]
func DefineInterruptibleTool[In, Out, Resume any](g *genkit.Genkit, name, description string,
fn aix.InterruptibleToolFunc[In, Out, Resume], opts ...ai.ToolOption) *aix.InterruptibleTool[In, Out, Resume]

The entry points on the agent itself:

func (a *aix.Agent[State]) Run(ctx context.Context, input *aix.AgentInput,
opts ...aix.InvocationOption[State]) (*aix.AgentOutput[State], error)
func (a *aix.Agent[State]) RunText(ctx context.Context, text string,
opts ...aix.InvocationOption[State]) (*aix.AgentOutput[State], error)
func (a *aix.Agent[State]) Connect(ctx context.Context,
opts ...aix.InvocationOption[State]) (*aix.AgentConnection[State], error)

AgentOutput and AgentConnection are generic over the same State, so a helper signature is *aix.AgentOutput[TaskState], never a bare *aix.AgentOutput. See Run and stream agents for the field sets.

The pirate.go file of go/samples/basic-agents is the shortest working version of this shape: one inline prompt, one file store, no state of its own.

  • aix.WithSessionStore(store) persists snapshots and switches the agent to server-managed state.
  • aix.WithStateTransform(fn) redacts or reshapes session state returned to clients and snapshot readers.
  • aix.WithStreamTransform[State](fn) redacts or reshapes each streamed chunk before it is sent to clients.
  • aix.WithDescription[State](text) adds a human-readable description to action metadata and developer tooling.
  • aix.WithNamedPrompt[State](name, input) points DefinePromptAgent at a specific registered prompt and renders input.

Typed options are deliberately strict. Passing a state option with the wrong State type fails at compile time.

aix.InlinePrompt is a []ai.PromptOption, so every option genkit.DefinePrompt accepts is valid inside it, including ai.WithConfig, ai.WithOutputType, ai.WithInputType, ai.WithMaxTurns, and ai.WithDocs or ai.WithDocsFn.

An agent turn runs the same tool loop as genkit.Generate, with the same default cap of five tool-call iterations. ai.WithMaxTurns returns a CommonGenOption, which embeds PromptOption, so it belongs in the inline prompt:

aix.InlinePrompt{
ai.WithModelName("googleai/gemini-flash-latest"),
ai.WithSystem("Manage a task list. Use tools when changing tasks."),
ai.WithTools(addTaskTool, toggleTaskTool),
ai.WithMaxTurns(12),
}

The cap bounds the tool loop inside one turn, not the number of turns in a conversation. A turn that exceeds it stops with ai.ErrMaxTurnsExceeded, and because a limit the caller set is a caller stop rather than a failure, the invocation reports aix.AgentFinishReasonAborted and keeps the tool rounds that completed. See Agent error handling.

Because ai.WithDocsFn is a prompt option, an agent can retrieve documents on every turn and pass them to the model as context:

grounded := genkitx.DefineAgent(g, "grounded",
aix.InlinePrompt{
ai.WithModelName("googleai/gemini-flash-latest"),
ai.WithSystem("Answer from the provided documents. Say so when they do not cover the question."),
ai.WithDocsFn(func(ctx context.Context, _ any) ([]*ai.Document, error) {
history := ai.HistoryFromContext(ctx)
if len(history) == 0 {
return nil, nil
}
query := history[len(history)-1].Text()
res, err := genkit.Retrieve(ctx, g,
ai.WithRetriever(retriever),
ai.WithTextDocs(query),
)
if err != nil {
return nil, fmt.Errorf("retrieve context for %q: %w", query, err)
}
return res.Documents, nil
}),
},
aix.WithSessionStore(store),
)

WithDocsFn[In] receives the prompt’s input, and an agent has no per-turn input unless the inline prompt sets ai.WithInputType, so In is the zero value by default. Build the query from ai.HistoryFromContext(ctx) instead, whose last element is the question this turn is about to answer. See Retrieval-augmented generation for retrievers and indexers.

Agent tools use the same constructors as any other tool. Each one reaches the active session the same way, so the choice is about signature and capability:

  • genkit.DefineTool hands the handler an *ai.ToolContext. It embeds context.Context, so aix.SessionFromContext[State](ctx) works on it directly, and it adds Interrupt, IsResumed, Resumed, and OriginalInput.
  • genkitx.DefineTool hands the handler a plain context.Context, plus tool.AttachParts for returning extra content parts alongside the output.
  • genkitx.DefineInterruptibleTool adds a typed resume parameter for tools that pause and wait for an answer.

See Tool calling, Sessions and state, and Agent interrupts.

Rendering always builds the request in one order: the system message, then the conversation, then the user prompt. On every turn the runtime hands the session’s conversation to the render, and where it lands depends on what the prompt declares.

  • The prompt declares no conversation, the common case of ai.WithSystem alone. The session’s messages are placed for you, between the system message and the user prompt.
  • The prompt declares a conversation with ai.WithMessages or ai.WithMessagesFn. The prompt owns placement, so the session’s messages are not placed for you. A function reads them with ai.HistoryFromContext(ctx) and returns them where it wants them.
  • The prompt declares the conversation as a template, with ai.WithMessagesTemplate or the body of a .prompt file. The session’s messages land at {{history}}, or, when the template has no such marker, immediately before the template’s final user message.

The conversation handed to the render already ends with this turn’s user message, so ai.HistoryFromContext(ctx) inside ai.WithSystemFn, ai.WithMessagesFn, or ai.WithDocsFn sees the current question as its last element. A content function can build a retrieval query or a state summary from the message it is about to answer, with no backwards walk.

An agent’s typed custom state is exposed to its own templates as {{@state.fieldName}}, JSON-serialized and re-evaluated on every render, so a tool that updates state changes the next turn’s instruction with no extra wiring. It works in the template forms only: ai.WithSystem, ai.WithPrompt, ai.WithMessagesTemplate, and .prompt bodies. The function forms take their text verbatim and never compile a template, so read state there with aix.SessionFromContext[State](ctx) as shown in Sessions and state. Prefer {{@state.…}} for straight interpolation and ai.WithSystemFn when the instruction needs Go logic.

ai.WithMessagesTemplate and ai.WithMessages or ai.WithMessagesFn have no meaningful combination, since the template is the whole conversation. Passing both to one prompt definition panics with a message naming the prompt.

Claiming the slot is what lets an agent rewrite its own history. Whatever the function returns is both what the model sees and what the session keeps, so a summary or a trim persists into the next turn instead of being recomputed from a growing transcript.

support := genkitx.DefineAgent(g, "support",
aix.InlinePrompt{
ai.WithModelName("googleai/gemini-flash-latest"),
ai.WithSystem("Be helpful."),
ai.WithMessagesFn(func(ctx context.Context, _ any) ([]*ai.Message, error) {
history := ai.HistoryFromContext(ctx)
const keep = 6
if len(history) <= keep {
return history, nil
}
dropped := len(history) - keep
note := ai.NewUserTextMessage(fmt.Sprintf("(%d earlier messages omitted)", dropped))
return append([]*ai.Message{note}, history[dropped:]...), nil
}),
},
aix.WithSessionStore(store),
)

Each content slot has a template form and a function form: ai.WithSystem and ai.WithSystemFn, ai.WithPrompt and ai.WithPromptFn, ai.WithMessagesTemplate and ai.WithMessagesFn. Template text is compiled against the prompt input, so it can interpolate fields. What a function returns is used verbatim, which is what makes it safe for user-supplied content and for text containing braces. A function also receives the turn’s context, so it can read session state as well as the conversation. See Sessions and state for an agent whose instruction is rebuilt from typed state on every turn.

DefinePromptAgent wraps a prompt already registered in the prompt registry. With no prompt-source option, it uses a prompt with the same name as the agent.

chef := genkitx.DefinePromptAgent[ChefState](g, "chef",
aix.WithSessionStore(store),
aix.WithDescription[ChefState]("Chef assistant loaded from ./prompts/chef.prompt"),
)

Use WithNamedPrompt when several agents share one prompt or when the prompt name differs from the agent name.

friendlyChef := genkitx.DefinePromptAgent[ChefState](g, "friendlyChef",
aix.WithNamedPrompt[ChefState]("chef", map[string]any{
"personality": "friendly",
}),
aix.WithSessionStore(store),
)

The prompt input is rendered at definition time as a smoke test. If it does not satisfy the prompt schema, the constructor panics during setup rather than during the first request. When the .prompt frontmatter names an input schema, register the matching Go type with genkit.DefineSchemasFor(g, ChefInput{}) before defining the agent, so the name resolves during that first render. Register the type whose name the frontmatter’s input.schema field uses, which is the prompt input type, not the agent’s state type.

The chef.go file and prompts/chef.prompt in go/samples/basic-agents show the whole arrangement: model, config, input schema, and default input live in the file, and the Go code carries only the agent wiring.

DefineCustomAgent gives you the agent runtime without the built-in prompt loop. Use it for a custom per-turn loop, multiple model calls in one turn, or direct session control. The function receives a Responder for streaming and a SessionRunner for turn processing, messages, custom state, artifacts, and snapshots.

coder := genkitx.DefineCustomAgent(g, "coder",
func(ctx context.Context, resp aix.Responder, sess *aix.SessionRunner[CoderState]) (*aix.AgentResult, error) {
// Your own per-turn loop. See Custom orchestration for the full pattern.
return sess.Result(), nil
},
aix.WithSessionStore(store),
aix.WithDescription[CoderState]("Concise code helper"),
)

See Custom orchestration for the runtime contract, a complete example, turn context, responder behavior, and failure handling. The coder.go file of go/samples/basic-agents wires the per-turn loop by hand while the framework still owns session state, snapshot writes, and the detach lifecycle.