# Recipe: AI chat > A Next.js-style web framework for Go: a folder under app/ is a route, HTML is written in Go, and nothing outside the standard library is imported. > /trilha/llms/recipes/ai-chat.txt - [AI chat](/trilha/cookbook/ai-chat): A chat with a model inside your app, from zero to the screen — where the key lives, what the page tells the model, who keeps the history, what happens when the provider says no, and how the test runs without a key. · 5623 tokens est. - [AI and agents](/trilha/learn/ai-and-agents): Call a model, give tools to an agent, hand the conversation over between agents, use and expose MCP, and stream the answer. · 3329 tokens est. - [Database](/trilha/cookbook/database): One pool for the process, queries that carry the request's context, a transaction that rolls back on its own, and migrations applied in order. · 2289 tokens est. - [ai](/trilha/reference/ai): OpenAI-compatible client, tools, agents, handoffs and composition. · 2393 tokens est. 4 page(s) · 13634 tokens est. in all --- [Learn](/trilha/learn/ai-and-agents) teaches the pieces: a call, a tool, an agent, `ai.Serve` and `ui.Chat`. This is the whole thing assembled, in the order an application needs it, with the decisions nobody writes down: where the key lives, what the page tells the model, who keeps the history, what the visitor reads when the provider is having a bad day, and how the test runs with no key and no network. The screen it ends at is [the demo](/trilha/demos/ai-chat): a support chat next to the order it is about. ## 1. The client The key comes from the environment, like the database URL. It is never in the code, never in a commit, and never in a log — `ai.Client` keeps it out of both. ```go // AIChatClient is the provider, built once at startup. The key never appears // in the code and never in a log — it comes from the environment, which is the // same place the database URL comes from. var AIChatClient = newAIChatClient() // newAIChatClient picks the provider from what the environment has. Anthropic // answers the same chat-completions shape at its OpenAI-compatible base URL, // so one Client covers both and the app has no provider branch past this // function. With neither key the client still builds, and only a request // fails: a missing key must not stop the app from starting. func newAIChatClient() *ai.Client { c := ai.NewFromEnv() // OPENAI_API_KEY, OPENAI_BASE_URL, TRILHA_AI_MODEL if key := os.Getenv("ANTHROPIC_API_KEY"); key != "" && os.Getenv("OPENAI_API_KEY") == "" { c.BaseURL, c.APIKey = "https://api.anthropic.com/v1", key } c.HTTPClient = &http.Client{Timeout: AIChatTimeout} return c } ``` The timeout is a decision and not a default: ```go // AIChatTimeout is how long one answer may take. The default client waits two // minutes, which is longer than anybody sits in front of a support chat: a // provider having a bad day should give up before the visitor does. const AIChatTimeout = 60 * time.Second ``` :::note `ai.Client` speaks the chat-completions shape. OpenAI, Anthropic (at its OpenAI-compatible base URL), Groq, OpenRouter, Ollama and vLLM all answer it, so switching provider is a base URL and a key, not a rewrite. ::: What an administrator changes without a deploy is not the key — it is the model, how much it invents, and who the assistant is told to be. That is a [settings section](/trilha/reference/app#settings): a struct, its tags and one screen the framework builds from it. ```go // AIChatConfig is the part of the assistant an administrator changes without a // deploy: which model answers, how much it invents, and who it is told to be. // The tags do three jobs at once — json stores it, form names the input, and // validate is the rule, the same one on the screen and on anything else that // saves it. type AIChatConfig struct { Model string `json:"model" form:"model" validate:"max=60" label:"Model" help:"the name the provider knows; empty keeps the one the environment chose"` Temperature float64 `json:"temperature" form:"temperature" validate:"min=0,max=2" label:"Temperature" help:"0 answers the same way every time; 2 invents"` Instructions string `json:"instructions" form:"instructions" validate:"required,max=2000" label:"Instructions" help:"who the assistant is, and what it must not answer"` } // AIChatSettings is the section, with the defaults that answer before anybody // has saved anything. It is a package var because configuration is not built // per request — it is read on every one. var AIChatSettings = trilha.NewSettings("ai-chat", AIChatConfig{ Temperature: 0.2, Instructions: "You are the support assistant of an online store. Answer about the " + "order the visitor has open, in the language the question was asked in. " + "When the answer is not in what you were told, say so instead of guessing.", }) ``` The agent is built per request, from a copy of the settings, so a change on the administration screen takes effect on the next message: ```go // AIChatAgent is the agent of one request. It is built per request and from a // copy of the settings, so changing the model on the administration screen // takes effect on the next message — without a restart, and without a request // already running seeing the agent change underneath it. func AIChatAgent() *ai.Agent { cfg := AIChatSettings.Get() return &ai.Agent{ Name: "support", Instructions: cfg.Instructions, Model: cfg.Model, Temperature: &cfg.Temperature, } } ``` :::note Putting the provider key in the section as well is a real option — `trilha.Secret` keeps it encrypted at rest and never sends it back to the screen. Do it when someone other than the deploy pipeline has to rotate it. ::: ## 2. The route The route is `ai.Serve` and what only the app knows around it: who is asking, how often they may ask, and what the page they are on means. ```go // AIChatPOST is app/api/chat/route.go, the whole route. ai.Serve reads the // message, runs the agent and answers: a stream of events for ui.chat.js, the // finished answer for a plain form submit. What the app adds around it is what // only the app knows — who is asking, how often they may ask, and what the // page they are on means. func AIChatPOST(c *trilha.Ctx) error { who := AIChatWho(c) if ok, after := AIChatLimit.Allow(who); !ok { c.Header("Retry-After", strconv.Itoa(after)) return trilha.Errorf(http.StatusTooManyRequests, "too many questions in a row; try again in %ds", after) } // The question is audited, the answer is not: the trail says who asked // what and when, and the conversation itself stays where it belongs. c.Audit("ai.chat.asked", who) return ai.ServeOpts{ HTML: ui.ChatHTML, Context: AIChatContext, Page: AIChatAnswerPage, }.Serve(c, AIChatClient, AIChatAgent()) } ``` `ai.Serve` reads the message, runs the agent and answers twice over: a stream of named events when the client asked for `text/event-stream`, the whole answer at once when it did not. The second one is what arrives when JavaScript is off — the same route, the same agent, the same audit line. ```go // AIChatAnswerPage answers the request that did not ask for a stream — the // plain form submit, which is what arrives when JavaScript is not there. It // runs after the whole answer is ready, so the turn goes into the log and the // visitor is sent back to the page that has it. A redirect and not a render: // reloading must not ask the model a second time. func AIChatAnswerPage(c *trilha.Ctx, res *ai.Result) error { id := c.Form("ctx.order_id") AIChatLog.Append(AIChatWho(c), c.Form("message"), res.Output) if id == "" { return c.Redirect("/") } return c.Redirect("/orders/" + url.PathEscape(id)) } ``` A redirect and not a render, for the reason every form has one: reloading the page must not ask the model a second time. ## 3. The screen `ui.Chat` is the conversation, the field and the button; `ui.ChatScript` is the client that makes the answer arrive word by word. Neither of them is required for the page to work. ```go // AIChatScreen is the page with the chat in it: the conversation, the field // and the button, plus the script that makes the answer arrive word by word. // Without the script the same form posts to the same route and the page comes // back with the answer in it — nothing on the screen depends on JavaScript. func AIChatScreen(c *trilha.Ctx, o AIChatOrder) h.Node { return h.Div(h.Class("ui-stack"), ui.Chat(c, ui.ChatOpts{ Action: "/api/chat", History: AIChatLog.Read(AIChatWho(c)), Greeting: "Ask anything about this order — where it is, when it arrives, what it cost.", // What the page knows travels with every message, as JSON with // the script and as hidden fields without it. Context: map[string]string{"order_id": o.ID}, }), ui.ChatScript(c), ) } ``` `Context` is the part that makes an assistant useful instead of impressive: what the page knows and the model does not. Each entry becomes a hidden `ctx.` field, sent with the message by the script and by the plain form alike, and read on the other side by `ai.ServeOpts.Context`: ```go // AIChatContext turns the ctx.* fields the page sent into the sentence the // model reads before the conversation. It is what the page knows and the model // does not. // // The fields come from the request, so they are what the visitor sent and not // what the server knows: the lookup takes the owner as well as the id, which // makes it the permission check. An id that is not theirs finds nothing, and // the model is told nothing. // // The messages are user turns and not system ones. The history travels through // the browser, so a run drops every system message it finds in it — that is // what stops a page from being talked into new instructions, and it applies // here too. What the app really wants to fix goes in Agent.Instructions, which // no request can touch. func AIChatContext(c *trilha.Ctx, fields map[string]string) []ai.Message { id := fields["order_id"] if id == "" { return nil } o, ok := AIChatFindOrder(AIChatWho(c), id) if !ok { return []ai.Message{ai.User("I have no order open. Answer only general questions about the store.")} } return []ai.Message{ai.User(fmt.Sprintf( "I am looking at order %s, placed on %s by %s. Status: %s. Total: %s. Answer about this order and no other.", o.ID, o.Placed.Format("2006-01-02"), o.Customer, o.Status, o.Total))} } ``` The record it reads is the app's, and it is the only thing the model is told about: ```go // AIChatFindOrder is the app's query, with the owner in the WHERE clause. It // is a var because this file has no database; in an application it is the // same function the order page calls. The default finds nothing, which is the // safe way to notice it was never wired. var AIChatFindOrder = func(who, id string) (AIChatOrder, bool) { return AIChatOrder{}, false } ``` :::note The `ctx.*` fields come from the request, so they are what the visitor sent — not what the server knows. The lookup takes the owner as well as the id, which is what makes it the permission check instead of a lookup. Treat an id there exactly like an id in the URL. ::: Model text is Markdown, and `ui.ChatHTML` is the adapter that renders it the same way the history is rendered: `