Prompt management
A prompt is the text your agent sends to a model, such as a system prompt that tells a support assistant how to handle refunds. Goa-AI treats prompts as part of your design:
- You declare each prompt, its role, and the arguments it takes in the design.
goa genchecks every template against those arguments and generates a typed function that renders it.- At runtime you can replace a prompt’s text for one customer, one session, or everyone, without rebuilding or redeploying.
- Rendering returns the exact prompt versions it used. Attach them to the model request that sends the text, and the request’s trace shows which text produced which answer.
Version prerequisite
Design-declared prompts (SystemPrompt, Prompt, Template, Role, Args),
generated prompt packages, and typed override validation landed in
goa.design/goa-ai after v0.90.0. Until the next tagged release, pin
goa.design/goa-ai to commit fb00df97927e61cfa444423e77584b950d753bda
(goa-ai’s module requirement automatically selects a compatible goa.design/goa/v3):
go get goa.design/goa-ai@fb00df97927e61cfa444423e77584b950d753bda
Concepts
Baseline prompt. The prompt as declared in the design: its ID (for example
support.assistant.system), its role (system, user, tool, or synthesis),
its template text, and the fields the template may use. The runtime registers each
baseline once, and a registered baseline never changes. Its version is derived
from the template text, so changing the text in the design produces a new
version.
Override. Replacement text for one prompt, saved in a prompt store, that applies only within a scope. Each override gets its own version.
Scope. Where an override applies. A scope has an optional session ID and any
labels you choose, such as account=acme or region=eu. An override applies to
a request when:
- the override has no session ID, or its session ID equals the request’s; and
- every label on the override is present on the request with the same value.
An override with no session and no labels applies everywhere.
Which override wins. When several overrides apply, the runtime picks one:
- An override with a session ID beats one without.
- Otherwise, the override with more labels beats the one with fewer.
- Otherwise, the newer override wins.
If no override applies, the runtime renders the baseline.
For example, with these overrides saved for support.assistant.system:
| Override scope | Applies to a request in session s-42 for account=acme, region=eu? |
|---|---|
| no session, no labels | yes |
account=acme | yes |
account=acme, region=eu | yes, and wins: it has the most labels |
account=globex | no: the label value differs |
Declare prompts in the design
Use SystemPrompt inside Agent for the agent’s baseline system prompt
(<service>.<agent>.system, role system), Prompt inside Agent for
agent-owned prompts (<service>.<agent>.<name>, default role user), and
Prompt inside Service for prompts shared across agents or non-agent callers
(<service>.<name>):
var _ = Service("support", func() {
Prompt("shared_safety", func() {
Description("Shared safety rules")
Role("system")
Template("Never disclose internal credentials or personal data.")
})
Agent("assistant", "Answer refund questions", func() {
SystemPrompt(func() {
Description("Refund policy")
Args(func() {
Attribute("account_name", String)
Attribute("refund_limit", Int)
Required("account_name", "refund_limit")
})
Template("You help {{ .AccountName }} customers. " +
"Approve refunds up to ${{ .RefundLimit }}. " +
`{{ render "support.shared_safety" }}`)
})
})
})
Templates render against the generated Go argument struct
(SystemPromptArgs or <Name>PromptArgs), so template expressions use the
generated Go field names ({{ .AccountName }}, {{ .RefundLimit }}).
goa gen emits agent prompt helpers in
gen/<service>/agents/<agent>/prompts.go (SystemPromptID,
Prompt<Name>ID, SystemPromptArgs, <Name>PromptArgs,
PromptSpecSystem(), PromptSpec<Name>(), PromptSpecs(),
RegisterPrompts, RenderSystemPrompt, Render<Name>Prompt,
RenderSystemPromptWithRegistry, and Render<Name>PromptWithRegistry) and
service prompt helpers in gen/<service>/prompts/prompts.go.
Register prompts
The runtime must know a prompt’s baseline before anything can render it.
Agent prompts register with their agent. AgentRegistration.PromptSpecs
lists the prompts an agent owns, and Runtime.RegisterAgent handles them:
- It checks every listed prompt first, before it registers anything. A
template that does not parse, a duplicate ID, or an ID that is already
registered fails the whole call. So does a template that uses a field its
ArgsTypedoes not declare, when the prompt declaresArgsType. - It registers the agent with the workflow engine.
- Only when that succeeds, it adds the prompts to
Runtime.PromptRegistry.
A prompt that fails the checks stops RegisterAgent before the agent reaches
the workflow engine. Building a registration value has no side effects: nothing
is registered until you call RegisterAgent.
Service-owned prompts belong to a service rather than one agent, so several
agents and non-agent code can share them. Their PromptSpec.AgentID is empty,
and you register them on Runtime.PromptRegistry with the generated service
package’s RegisterPrompts helper (or Runtime.PromptRegistry.Register):
if err := supportprompts.RegisterPrompts(rt.PromptRegistry); err != nil {
return err
}
if err := assistant.RegisterAssistantAgent(ctx, rt, assistant.AssistantAgentConfig{
Planner: myPlanner,
}); err != nil {
return err
}
NewAssistantRegistration(rt, cfg) populates AgentRegistration.PromptSpecs
from the generated PromptSpecs() slice without mutating the registry, and
RegisterAssistantAgent calls rt.RegisterAgent(ctx, reg).
Prompts you register by hand keep working. Build a prompt.PromptSpec and
pass it to Runtime.PromptRegistry.Register, as before. A spec with a nil
ArgsType accepts untyped data, so its template is checked for syntax and
declared Funcs rather than struct field names.
Render a prompt and record what was used
Rendering returns the text and the list of every prompt version used to build
it. That list includes prompts pulled in with {{ render "<prompt-id>" . }}, in
the order they were rendered. Put that list on the model request that uses the
text:
content, err := assistant.RenderSystemPrompt(ctx, input.Agent, assistant.SystemPromptArgs{
AccountName: "Acme",
RefundLimit: 100,
})
if err != nil {
return nil, err
}
system := &model.Message{
Role: model.ConversationRoleSystem,
Parts: []model.Part{model.TextPart{Text: content.Text}},
}
resp, err := modelClient.Complete(ctx, &model.Request{
Messages: append([]*model.Message{system}, input.Messages...),
PromptRefs: content.Refs,
})
Use content.Refs, not []prompt.PromptRef{content.Ref}. content.Ref names
only the top-level prompt and drops the prompts it composed.
The runtime does not attach prompt versions for you: each request carries the
list its caller put on it. If a planner renders a classification prompt and a
separate answer prompt for two model calls in the same turn, give each request
the Refs from its own render. The gen_ai.chat client span records
gen_ai.prompt.ids, gen_ai.prompt.versions, and goa_ai.prompt.refs
("<id>@<version>") from that request.
Change a prompt at runtime
Saving an override replaces a prompt’s text for one scope. The runtime checks
the new template before it saves anything. Generated prompts set
PromptSpec.ArgsType to reflect.TypeFor[<Name>PromptArgs]() when Args is
declared, or reflect.TypeFor[struct{}]() when the prompt takes no arguments,
so a template that uses an unknown field is rejected at save time instead of
failing later on every render:
scope := prompt.Scope{
Labels: map[string]string{"account": "acme"},
}
if err := rt.PromptRegistry.ValidateOverride(assistant.SystemPromptID, newTemplate); err != nil {
return err
}
if err := rt.PromptRegistry.SetOverride(ctx, assistant.SystemPromptID, scope, newTemplate, map[string]string{
"author": "ops@acme.com",
}); err != nil {
return err
}
Reset an override
Resetting removes a prompt’s overrides at one exact scope, so the next render falls back to the next override that applies, or to the baseline.
Reset is destructive:
- It permanently deletes every saved override for that prompt whose scope exactly equals the one you pass: the same session ID and the same labels, no more and no fewer.
- Overrides at other scopes are untouched, including broader and narrower ones.
Historyafterwards returns only the overrides that remain. Reset does not keep an audit trail of what it deleted. If you need one, record it before you reset.
For example, resetting support.assistant.system at scope account=acme deletes
the overrides saved at exactly account=acme. An override at
account=acme, region=eu remains, and so does one with no labels. A request
for account=acme, region=eu keeps using the narrower override. A request for
account=acme alone now uses the override with no labels.
if err := rt.PromptRegistry.ResetOverride(ctx, assistant.SystemPromptID, prompt.Scope{
Labels: map[string]string{"account": "acme"},
}); err != nil {
return err
}
Store overrides
Overrides live in a prompt store that you pass to the runtime with
runtime.WithPromptStore. Goa-AI includes two:
prompt.NewInMemoryStore(), for tests and single-process development;- the MongoDB store in
features/prompt/mongo, for production.
Without a prompt store, every render uses the baseline.
Custom stores
A custom prompt.Store implements Reset with the exact-scope, destructive
behavior described above. Treat label keys and values as plain data: a key may
contain dots, and a value may start with $.
type Store interface {
Resolve(ctx context.Context, promptID Ident, scope Scope) (*Override, error)
Set(ctx context.Context, promptID Ident, scope Scope, template string, metadata map[string]string) error
Reset(ctx context.Context, promptID Ident, scope Scope) error
History(ctx context.Context, promptID Ident) ([]*Override, error)
List(ctx context.Context) ([]*Override, error)
}
If your application implements a custom prompt.Store, add
Reset(ctx, promptID, scope) so it permanently deletes overrides whose
PromptID, Scope.SessionID, and Scope.Labels match scope (and returns
nil when no override exists at that exact scope).