Receitas
Um agente de IA
Um agente que age sobre os dados do app — ferramentas que leem o que a pessoa pode ler, uma escrita que espera alguém decidir, dois agentes, os passos na tela, o rastro, um teste roteirizado e o mesmo conjunto no MCP.
ctx --pack · est. 40723 tokens est. · llms.txt da receita
A receita do chat de IA coloca uma conversa dentro do app. Esta é a pergunta do segundo dia: o assistente para de responder e começa a fazer — procurar um pedido, ler a API, pedir um cancelamento. Quatro coisas precisam ser verdade antes que isso seja uma boa ideia, e nenhuma página as diz juntas:
- ele lê o que a pessoa que perguntou pode ler, e mais nada;
- o que ele pode fazer sozinho está separado do que espera uma pessoa;
- o que ele fez fica escrito, ferramenta por ferramenta;
- e dá para testar tudo isso sem gastar chamada.
O domínio é pequeno e o mesmo da receita do chat: pedidos e clientes de uma loja. Todo bloco abaixo é uma declaração de examples/cookbook/aiagent.go, que compila e é testado com o resto do repositório. Ela termina em uma demo, onde uma corrida inteira — o repasse, as ferramentas, a fila — acontece na sua frente.
1. Ferramentas que leem#
Uma ferramenta é uma função com nome, descrição e um schema JSON. O que a torna sua é o fecho em volta dela: a requisição está dentro, então o visitante está dentro.
// AIAgentWho is who is asking: the signed-in visitor, or the address when
// there is nobody. It is the customer of every query and the tenant of the
// index — one answer, in one place, so no tool can be written without it.
func AIAgentWho(c *trilha.Ctx) string {
if a := c.Actor(); a.Subject != "" {
return a.Subject
}
return c.ClientIP()
}O índice é o do próprio app, o mesmo da caixa de busca. O recorte é do índice, não da ferramenta:
// AIAgentSearch is the app's index, the one the search box on the site header
// already uses. The tenant of a document is the customer it belongs to, so a
// query is scoped by the index and not by a filter every tool has to remember
// to write.
var AIAgentSearch = trilha.NewSearch(trilha.SearchOpts{Tenant: AIAgentWho}).// AIAgentSearchTool is the first tool: it reads. The visitor is inside the
// closure and not in the arguments, so there is no id the model can send that
// widens what it sees — the agent reads what the person would read.
func AIAgentSearchTool(c *trilha.Ctx) *ai.Tool {
return ai.NewTool("search_orders",
"Find the customer's own orders by words: an order number, a status, a date.",
ai.Schema(`{"type":"object","properties":{"query":{"type":"string","description":"words to look for"}},"required":["query"]}`),
ai.Typed(func(ctx context.Context, in struct{ Query string }) (string, error) {
res, err := AIAgentSearch.Query(c, in.Query, trilha.SearchQuery{Limit: AIAgentHits})
if err != nil {
return "", err
}
if res.Total == 0 {
return "nothing found for " + strconv.Quote(in.Query), nil
}
var b strings.Builder
for _, g := range res.Groups {
for _, hit := range g.Hits {
fmt.Fprintf(&b, "%s %s: %s (%s)\n", g.Kind, hit.ID, hit.Title, hit.URL)
}
}
return b.String(), nil
}))
}Leia o fecho outra vez: quem chamou não é argumento. Não existe id que o modelo possa inventar para ver mais, porque a única coisa que ele manda são palavras para procurar. Esse é o modelo de permissão inteiro de uma ferramenta de leitura — o search faz o resto, e um tipo negado não deixa nem o número na contagem.
A API que você já tem#
A segunda ferramenta lê um pedido inteiro, e já existe uma rota que faz isso. Ela não é declarada de novo: ela é chamada, como requisição, com a credencial de quem perguntou.
// AIAgentOrderGET is app/api/orders/[id]/route.go, the app's own API, written
// for people before it was written for agents. The recipe does not declare it
// a second time.
func AIAgentOrderGET(c *trilha.Ctx) error {
o, ok := AIAgentOrders.Find(AIAgentWho(c), c.Param("id"))
if !ok {
return trilha.ErrNotFound
}
c.Audit("order.read", o.ID)
return c.JSON(http.StatusOK, map[string]string{
"id": o.ID, "status": o.Status, "total": o.Total,
"placed": o.Placed.Format("2006-01-02"),
})
}// AIAgentSignedIn is the chain that route already had. Probe runs it — that is
// the whole point of the tool below.
func AIAgentSignedIn(c *trilha.Ctx, next trilha.Next) error {
if c.Actor().Subject == "" {
return trilha.Errorf(http.StatusForbidden, "sign in to see your orders")
}
return next()
}// AIAgentAPITool is the second tool, and it is the same API. The route is
// called as a request — with the caller's own credential, marked as arriving
// from the agent — and App.Probe asks the route's chain first, so a caller who
// may not reach the handler gets a tool that says no instead of a handler that
// ran. It is cookbook/api-as-tools seen from the agent's side.
func AIAgentAPITool(c *trilha.Ctx) *ai.Tool {
return ai.NewTool("get_order",
"Read one order in full: status, total and the date it was placed.",
ai.Schema(`{"type":"object","properties":{"id":{"type":"string","description":"the order number"}},"required":["id"]}`),
ai.Typed(func(ctx context.Context, in struct{ ID string }) (string, error) {
req, err := http.NewRequestWithContext(ctx, "GET", "/api/orders/"+url.PathEscape(in.ID), nil)
if err != nil {
return "", err
}
req.Header.Set("Accept", "application/json")
for _, name := range []string{"Authorization", "Cookie", "Accept-Language", "X-Request-ID"} {
if v := c.Request().Header.Get(name); v != "" {
req.Header.Set(name, v)
}
}
req.Host, req.RemoteAddr = c.Request().Host, c.Request().RemoteAddr
req = trilha.WithVia(req, "agent")
if !c.App().Probe(req) {
return "you may not read that order", nil
}
rec := &AIAgentRecorder{Status: http.StatusOK}
c.App().Handler().ServeHTTP(rec, req)
body := strings.TrimSpace(rec.Body.String())
if rec.Status >= 400 {
return "the API answered " + strconv.Itoa(rec.Status) + ": " + body, nil
}
return body, nil
}))
}Duas linhas carregam a receita. O App.Probe roda a cadeia de middleware da rota — a sessão, a chave, a política — sem rodar o handler, e responde se aquela pessoa passaria; quem não passaria recebe uma ferramenta que diz não, em vez de um handler que rodou. O trilha.WithVia(req, "agent") marca como a chamada chegou, então o registro de auditoria que o handler escreve diz agent e não session: seis meses depois, "o que o assistente fez" é um filtro e não uma investigação.
O gravador é a ponte inteira:
// AIAgentRecorder collects what a route answers when the caller is a tool and
// not a socket. Fifteen lines instead of a second handler: whatever the route
// answers a browser is what the model reads.
type AIAgentRecorder struct {
Status int
Body bytes.Buffer
header http.Header
}2. Uma ferramenta que escreve, com uma pessoa no meio#
"Cancele o pedido" é onde toda demo de agente vira, em silêncio, uma má ideia. A resposta da receita é que a ferramenta não cancela nada: ela abre um pedido, e diz que abriu.
// AIAgentCancelTool is the third tool, and the one that does not do what it
// says: cancelling an order is not the model's to do. It opens a request in
// the queue, tells the model that a person has it, and changes nothing. What
// the model reads back is the truth, which is what keeps it from telling the
// customer the order is cancelled.
func AIAgentCancelTool(c *trilha.Ctx) *ai.Tool {
return ai.NewTool("cancel_order",
"Ask a person to cancel an order. This does not cancel anything: it opens a request somebody from support decides.",
ai.Schema(`{"type":"object","properties":{"order_id":{"type":"string"},"reason":{"type":"string","description":"what the customer said"}},"required":["order_id","reason"]}`),
// The tag is not decoration: the argument the model sends is the name
// in the schema, and ai.Typed hands it to encoding/json.
ai.Typed(func(ctx context.Context, in struct {
OrderID string `json:"order_id"`
Reason string `json:"reason"`
}) (string, error) {
o, ok := AIAgentOrders.Find(AIAgentWho(c), in.OrderID)
if !ok {
return "there is no order " + in.OrderID + " on this account", nil
}
id, err := trilha.Use[*approval.Approvals](c).Open(c, approval.Request{
Kind: AIAgentCancelKind,
Subject: "Cancel order " + o.ID,
Target: "/orders/" + o.ID,
Assign: approval.Role("support"),
Due: time.Now().Add(AIAgentCancelDue),
Data: map[string]string{"order_id": o.ID, "customer": o.Customer, "reason": in.Reason},
})
if err != nil {
return "", err
}
return "request " + id + " is waiting for a person to decide; the order has not changed", nil
}))
}O que o modelo lê de volta é a verdade — esperando, não mudou —, e é isso que o impede de dizer ao cliente que o pedido foi cancelado. A frase na descrição da ferramenta também não é enfeite: a descrição é o único lugar onde o modelo aprende que essa ferramenta pede em vez de fazer.
A decisão é o único caminho da conversa até os dados:
// AIAgentCancelled is what the decision means, and the only path from the
// conversation to the data. It runs after the decision is written, so a
// failure here does not undo somebody's choice.
func AIAgentCancelled(c *trilha.Ctx, r approval.Record) error {
if r.State != approval.Approved {
return nil
}
return AIAgentOrders.Cancel(r.Data["customer"], r.Data["order_id"])
}A fila, o que uma decisão significa e a conferência de que a rota da ferramenta existe:
// AIAgentSetup is app/setup.go: the queue, what a decision means, and the
// check that says the tool's route is there. The check is a health check and
// not a panic at startup because Setup runs before the routes are registered —
// an app whose agent points at a route nobody wrote must fail its own probe,
// not fail on the day somebody asks.
func AIAgentSetup(a *trilha.App) error {
queue := approval.New(approval.Options{
Logger: a.Logger(),
Roles: func(c *trilha.Ctx) []string { return AIAgentRoles(c) },
})
queue.On(AIAgentCancelKind, AIAgentCancelled)
trilha.Provide(a, queue)
a.Check("agent-tools", func(context.Context) error {
if r, ok := a.Route(AIAgentOrderRoute); !ok || r.Methods["GET"] == nil {
return fmt.Errorf("the get_order tool calls GET %s, and no route answers it", AIAgentOrderRoute)
}
return nil
})
return queue.Setup(a)
}A tela onde alguém decide é o ui.Inbox, uma tabela e dois formulários sem JavaScript:
// AIAgentInboxPage is that screen: what is waiting for whoever is reading, and
// two buttons. Who may decide is the package's answer and not this screen's.
func AIAgentInboxPage(c *trilha.Ctx) (h.Node, error) {
queue := trilha.Use[*approval.Approvals](c)
waiting, err := queue.Inbox(c, approval.ListParams{})
if err != nil {
return nil, err
}
now := time.Now()
rows := make([]ui.InboxRow, 0, len(waiting))
for _, r := range waiting {
rows = append(rows, ui.InboxRow{
ID: r.ID, Kind: r.Kind, Subject: r.Subject, Target: r.Target,
State: r.State, Due: r.Due, Late: r.Late(now), By: r.By, Reason: r.Reason,
})
}
c.SetTitle("Approvals")
return ui.Stack(
ui.PageHeader("Approvals"),
ui.Inbox(c, rows, ui.InboxOpts{Decide: AIAgentInboxRoute, CSRF: trilha.CSRFInput(c)}),
), nil
}O resto da fila — quem pode decidir, o que expira, o que o On pode e não pode desfazer — está em approval. O que importa aqui é o formato: o modelo abre um pedido e nunca fecha um.
3. Dois agentes#
A triagem responde o que dá e repassa o que não dá. O repasse é uma ferramenta que o framework escreve (transfer_to_billing), então é o modelo que decide usá-la.
// AIAgentBilling is the specialist: it reads orders and asks for
// cancellations, and it says out loud that it asks rather than does.
func AIAgentBilling(c *trilha.Ctx) *ai.Agent {
return &ai.Agent{
Name: "billing",
Instructions: "You handle orders, payments and refund requests for an online store. " +
"Use the tools; never state a fact about an order you did not read with one. " +
"A cancellation or a refund is a request a person decides: say that it was asked for, " +
"never that it was done.",
Tools: AIAgentTools(c),
MaxTurns: AIAgentMaxTurns,
}
}// AIAgentTriage is what answers first: it looks things up, answers what it can
// and hands the rest over. The handoff is the model's decision, which is why
// this is a handoff and not a Chain — see the recipe for when it is the other
// way around.
func AIAgentTriage(c *trilha.Ctx) *ai.Agent {
return &ai.Agent{
Name: "triage",
Instructions: "You are the first line of support of an online store. Answer questions about " +
"delivery and status yourself, with the search tool. Anything about money — refunds, " +
"charges, cancelling an order — transfer to billing.",
Tools: []*ai.Tool{AIAgentAudited(c, AIAgentSearchTool(c))},
Handoffs: []*ai.Agent{AIAgentBilling(c)},
MaxTurns: AIAgentMaxTurns,
}
}Depois de um repasse as instruções mudam e o histórico fica, então o financeiro lê o que o cliente já disse. Repare que os dois agentes não dividem a lista de ferramentas: a triagem não consegue cancelar um pedido, porque uma ferramenta que o agente não tem é uma ferramenta que ninguém convence ele a chamar.
Quando o repasse é a ferramenta errada. O repasse serve quando é a conversa que decide para onde ir. Quando é o app que sabe a ordem dos passos, escreva a ordem em Go:
ai.Chain(ctx, cli, input, a, b)— o b lê a saída do a. Extrair, depois resumir.ai.Parallel(ctx, cli, input, a, b)— os dois ao mesmo tempo, na ordem dos agentes.pesquisador.AsTool(cli, "...")— o segundo agente como função do primeiro, que continua dono da conversa.
Um passo que o app já conhece não deveria depender de o modelo lembrar de delegar. A maior parte dos sistemas "multiagente" é um Chain com passos a mais; veja composição.
4. A corrida na tela#
A rota é a mesma em que a receita do chat termina, com o agente no lugar do assistente:
// AIAgentPOST is app/api/chat/route.go. ai.Serve runs the agent and answers:
// the stream of events ui.chat.js reads — text, tool_call, tool_result,
// handoff — or, without JavaScript, the finished answer.
func AIAgentPOST(c *trilha.Ctx) error {
return ai.ServeOpts{
HTML: ui.ChatHTML,
Page: AIAgentAnswerPage,
}.Serve(c, AIAgentClient, AIAgentTriage(c))
}O ai.Serve roda o RunStream por baixo e emite os eventos conforme eles acontecem — text, tool_call, tool_result, handoff, done. O ui.Chat mostra esses passos quando você pede:
// AIAgentScreen is the page: the conversation next to the order it is about,
// with Steps on, so what the agent did is on the screen instead of in a log
// the customer cannot see.
func AIAgentScreen(c *trilha.Ctx, o AIAgentOrder) h.Node {
return h.Div(h.Class("ui-stack"),
ui.Chat(c, ui.ChatOpts{
Action: "/api/chat",
Greeting: "Ask about your orders — where they are, what they cost, or ask to cancel one.",
Context: map[string]string{"order_id": o.ID},
Steps: true,
}),
ui.ChatScript(c),
)
}Steps: true é decisão de produto, não chave de depuração. Um agente que diz "pedi ao atendimento para cancelar seu pedido" fica acreditável quando a linha acima diz cancel_order → request apr_… is waiting; sem ela, o cliente tem que confiar numa frase. Sem JavaScript o mesmo formulário posta na mesma rota e a página volta com a resposta pronta — o que precisa do script são os passos, não a resposta.
5. O rastro#
O rastro não é escrito dentro de cada ferramenta. Ele é escrito uma vez, em volta de todas:
// AIAgentAudited is the trail, one line per tool call. It wraps a tool instead
// of being written inside each one, so a tool added next month is audited by
// the fact that it went through AIAgentTools — and not by whoever wrote it
// remembering.
func AIAgentAudited(c *trilha.Ctx, t *ai.Tool) *ai.Tool {
call := t.Func
return &ai.Tool{
Name: t.Name, Description: t.Description, Parameters: t.Parameters,
Func: func(ctx context.Context, args json.RawMessage) (string, error) {
out, err := call(ctx, args)
fields := trilha.Fields{"arguments": string(args)}
if err != nil {
fields["error"] = err.Error()
}
c.Audit("ai.tool."+t.Name, aiAgentTarget(args), fields)
return out, err
},
}
}// AIAgentTools is the set, built per request because every one of them carries
// the caller. Nothing outside this function hands a tool to an agent, which is
// how the audit line above cannot be forgotten.
func AIAgentTools(c *trilha.Ctx) []*ai.Tool {
tools := []*ai.Tool{AIAgentSearchTool(c), AIAgentAPITool(c), AIAgentCancelTool(c)}
for i, t := range tools {
tools[i] = AIAgentAudited(c, t)
}
return tools
}Uma ferramenta acrescentada no mês que vem é auditada porque passou pelo AIAgentTools, e não porque quem a escreveu lembrou. Quanto a corrida custou é mais uma linha, na saída:
// AIAgentAnswerPage answers the submit that did not ask for a stream, and
// writes the line the tool trail does not have: what the whole run cost.
func AIAgentAnswerPage(c *trilha.Ctx, res *ai.Result) error {
c.Audit("ai.run", res.Agent.Name, trilha.Fields{
"turns": res.Turns, "steps": len(res.Steps), "tokens": res.Usage.TotalTokens,
})
if id := c.Form("ctx.order_id"); id != "" {
return c.Redirect("/orders/" + url.PathEscape(id))
}
return c.Redirect("/")
}Uma resposta em streaming não tem onde pôr essa linha — quando a corrida termina, o ai.Serve já acabou de escrever. Quando os números importam, a rota é o laço escrito à mão, com os mesmos nomes de evento:
// AIAgentStreamPOST is the same route written by hand, for when the run's own
// numbers have to reach the trail: ai.Serve has nowhere to put the Usage of a
// streamed answer, because the answer is already gone when the run ends. The
// events are the same ones, with the same names, so the screen does not change.
func AIAgentStreamPOST(c *trilha.Ctx) error {
s := c.Stream()
agent := AIAgentTriage(c)
res, err := ai.RunStream(c.Context(), AIAgentClient, agent, c.Form("message"), func(ev ai.Event) {
switch ev.Type {
case "text":
_ = s.Send("text", ev.Text)
case "tool_call", "tool_result", "handoff":
_ = s.JSON(ev.Type, map[string]any{
"agent": ev.Agent, "tool": ev.Step.Tool,
"arguments": ev.Step.Arguments, "output": ev.Step.Output, "to": ev.Step.HandoffTo,
})
}
})
if err != nil {
c.Log().Warn("ai agent", "err", err)
return s.JSON("error", map[string]string{"message": "The assistant could not finish this one."})
}
c.Audit("ai.run", res.Agent.Name, trilha.Fields{
"turns": res.Turns, "steps": len(res.Steps), "tokens": res.Usage.TotalTokens,
})
return s.JSON("done", map[string]any{"output": res.Output, "html": ui.ChatHTML(res.Output)})
}As duas terminam no mesmo lugar, ao lado de tudo o que as pessoas fizeram:
// AIAgentTrailPage is the trail on a screen: every tool the agent called, with
// the arguments it called them with, next to everything else people did.
func AIAgentTrailPage(c *trilha.Ctx) (h.Node, error) {
c.SetTitle("Audit")
return ui.Stack(
ui.PageHeader("Audit"),
ui.AuditTable(c, AIAgentTrail.Records()),
), nil
}O ui.AuditTable é a tela; o c.Audit é o destino. O Actor.Via é o que separa a chamada de um agente da de uma pessoa — por isso a ferramenta de leitura marca a requisição dela.
6. Testando#
O provedor é roteirizado: ele responde as chamadas de ferramenta na ordem em que precisam acontecer, e depois a frase. Sem chave, sem fatura e sem rede — o que é trocado é o http.Client que o framework ia chamar de qualquer jeito.
// AIAgentFake is the provider in a test: it answers the script in order and
// keeps every conversation it was sent. No key, no bill and no network — what
// is replaced is the http.Client the framework was always going to call, so
// the route, the tools and the queue run exactly as they do in production.
//
// When the script runs out it repeats its last answer, which is how the test
// for MaxTurns writes "a model that will not stop" in one line.
type AIAgentFake struct {
// Script is what the model answers, one entry per model call.
Script []AIAgentReply
mu sync.Mutex
asked int
sent [][]ai.Message
}O teste que importa é o que prova que a escrita não aconteceu:
// The point of the recipe: a tool that changes something does not change it.
// The model asks, the queue keeps the ask, and the order is still open until a
// person says otherwise.
func TestAIAgentCancelWaitsForAPerson(t *testing.T) {
c, fake, a := newAIAgentTest(t,
AIAgentReply{Calls: []AIAgentCall{{Name: "transfer_to_billing", Arguments: `{"reason":"the customer asked about an order"}`}}},
AIAgentReply{Calls: []AIAgentCall{{Name: "cancel_order", Arguments: `{"order_id":"1043","reason":"bought the wrong size"}`}}},
AIAgentReply{Text: "I asked for order 1043 to be cancelled; someone from support will confirm."},
)
ask(c, "cancel order 1043").WantStatus(303)
// The store did not move.
if o, _ := AIAgentOrders.Find(aiAgentMine.Customer, "1043"); o.Status != "shipped" {
t.Fatalf("the agent changed the order on its own: %+v", o)
}
// The queue did.
pending, err := trilha.Use[*approval.Approvals](a).List(context.Background(), approval.ListParams{Kind: AIAgentCancelKind})
if err != nil {
t.Fatal(err)
}
if len(pending) != 1 || pending[0].State != approval.Pending || pending[0].Data["order_id"] != "1043" {
t.Fatalf("the cancellation is not waiting in the queue: %+v", pending)
}
if !strings.Contains(pending[0].Data["reason"], "wrong size") {
t.Errorf("the reason the model gave is not on the record: %+v", pending[0].Data)
}
// And the model was told the truth: it asked, nothing happened yet.
if got := aiAgentLastTool(fake); !strings.Contains(got, "waiting") {
t.Errorf("the tool result must say the cancellation is waiting: %q", got)
}
// The trail says which tool ran, for whom.
if !aiAgentAudited(t, "ai.tool.cancel_order", "1043") {
t.Errorf("the tool call is not in the audit trail: %+v", AIAgentTrail.Records())
}
}O store não mudou, a fila tem o pedido com o motivo, e o modelo ouviu a verdade. A outra metade — uma pessoa aprova e aí sim o pedido muda — é o TestAIAgentDecisionCancelsTheOrder logo ao lado.
Outros dois valem a cópia. Quem recusa é a cadeia da rota, e o handler não roda:
func TestAIAgentToolStopsAtTheRouteChain(t *testing.T) {
c, _, _ := newAIAgentTest(t,
AIAgentReply{Calls: []AIAgentCall{{Name: "transfer_to_billing", Arguments: `{"reason":"the customer asked about an order"}`}}},
AIAgentReply{Calls: []AIAgentCall{{Name: "get_order", Arguments: `{"id":"1043"}`}}},
AIAgentReply{Text: "I cannot see that order."},
)
// No cookie: a visitor who is not signed in.
c.PostForm("/api/chat", url.Values{"message": {"where is 1043?"}}).WantStatus(303)
for _, r := range AIAgentTrail.Records() {
if r.Action == "order.read" {
t.Fatalf("the handler ran for a caller the chain refuses: %+v", r)
}
}
}E o MaxTurns é o cinto: um modelo que insiste em chamar ferramenta em vez de responder para com ai.ErrMaxTurns, e não na fatura.
// The belt: a model that only ever calls tools stops at MaxTurns instead of
// running until the bill notices.
func TestAIAgentStopsAtMaxTurns(t *testing.T) {
c, fake, _ := newAIAgentTest(t,
AIAgentReply{Calls: []AIAgentCall{{Name: "get_order", Arguments: `{"id":"1043"}`}}},
)
ask(c, "and again?").WantStatus(500)
if fake.Asked() != AIAgentMaxTurns {
t.Fatalf("the model was asked %d times, want %d", fake.Asked(), AIAgentMaxTurns)
}
}7. O mesmo conjunto, para o agente dos outros#
As ferramentas carregam quem chamou, então expor tudo por MCP é uma rota:
// AIAgentMCPPOST is the same set of tools for an agent that is not this app's:
// Claude, an editor, anything that speaks MCP. The server is built per request
// because the tools carry the caller — the host's key decides what the tools
// can see, exactly as the chat's session does.
func AIAgentMCPPOST(c *trilha.Ctx) error {
return mcp.NewServer("orders", "1.0", AIAgentTools(c)...).ServeHTTP(c)
}Aponte o Claude, o Cursor ou um editor para POST /mcp e ele recebe search_orders, get_order e cancel_order — inclusive o fato de que cancelar abre um pedido. O embrulho da auditoria continua rodando, e a fila também. Veja mcp para o protocolo e o id de sessão, e a sua API como ferramentas quando o que você quer expor é a /api/ inteira e não um conjunto escolhido a dedo.
8. A demo#
A /demos/ai-agent é esta página rodando: pergunte onde está o pedido e veja a chamada da ferramenta; peça para cancelar e veja o repasse, o pedido que entra na fila e a resposta dizendo que uma pessoa está com ele. É roteirizada no navegador — o site da documentação é estático —, mas o componente, os passos e a caixa de entrada são os reais.
Para onde ir depois#
- IA e agentes — as peças sozinhas: uma chamada, uma ferramenta, streaming, MCP.
- Referência do
ai—Agent,Run,RunStream, o contrato de eventos, composição. - Referência do
approval— a fila: responsáveis, prazos, estados. - Chat de IA — a chave, o histórico, os limites, a falha que o visitante lê.
examples/assistente— as mesmas peças num aplicativo completo.