Reference
ai
OpenAI-compatible client, tools, agents, handoffs and composition.
import "github.com/emersonjoe/trilha/ai" — no external dependencies.
Client#
| Field / function | Role |
|---|---|
NewFromEnv() *Client | reads OPENAI_API_KEY, OPENAI_BASE_URL (default ai.DefaultBaseURL, https://api.openai.com/v1) and TRILHA_AI_MODEL (or OPENAI_MODEL; default gpt-4o-mini) |
BaseURL, APIKey, Model string | direct configuration |
Headers map[string]string | extra headers (OpenRouter, Azure...) |
HTTPClient *http.Client | HTTP client (default with a 2 min timeout) |
Chat(ctx, Request) (*Response, error) | one call; Response.Text() and Response.ToolCalls() |
Stream(ctx, Request, func(Delta) error) error | chunked response; Delta.Content, Delta.ToolCalls, Delta.Usage at the end |
Non-2xx responses become *ai.Error{Status, Code, Message}.
Request and messages#
Request{Model, Messages, Tools, ToolChoice, Temperature, MaxTokens, ResponseFormat, Extra}. Extra map[string]any is merged into the JSON sent, for provider-specific parameters. ResponseFormat{Type: "json_schema", JSONSchema: ...} asks for structured output.
Constructors: ai.System(s), ai.User(s), ai.Assistant(s), ai.ToolResult(callID, s).
The shape on the wire#
The types of the protocol are exported, because a provider that differs in one field is the normal case and reading the answer by hand has to stay possible: Response.Choices is a []ai.Choice, each with its Message and its FinishReason; a call the model asks for is an ai.ToolCall{ID, Type, Function} whose Function is an ai.FunctionCall{Name, Arguments} — the arguments arrive as the JSON text the model wrote, not as a map, because that text is what the schema validates. What goes out describing a tool is an ai.ToolDef{Type, Function} over an ai.FunctionDef{Name, Description, Parameters, Strict}, which is what Tool.Def() builds from a *ai.Tool. Request has a MarshalJSON of its own so that Extra merges into the same object instead of nesting under a key no provider reads.
Tool#
func NewTool(name, description string, schema json.RawMessage, fn ToolFunc) *Tool
type ToolFunc func(ctx context.Context, args json.RawMessage) (string, error)
func Schema(s string) json.RawMessage // validates the JSON; panics at startup if invalid
func Typed[T any](fn func(ctx, in T) (string, error)) ToolFuncschema == nil means "no arguments". Errors and panics from the function become text for the model (error: ...) and show up in Step.Err.
Agent#
| Field | Role |
|---|---|
Name | identifies the agent in Step.Agent and in handoffs (transfer_to_<slug>) |
Instructions | system message |
Model | overrides the client's model |
Tools []*Tool | tools |
Handoffs []*Agent | agents this one may transfer the conversation to |
MaxTurns | limit of model calls per Run (default 10; exceeded → ErrMaxTurns) |
Temperature *float64, ResponseFormat | passed on every request |
func Run(ctx, cli *Client, agent *Agent, input string, history ...Message) (*Result, error)
func RunStream(ctx, cli *Client, agent *Agent, input string, fn func(Event), history ...Message) (*Result, error)Result{Output, Agent, Messages, Steps, Usage, Turns}. Messages serves as history for the next call (system messages from the history are ignored; the current agent's apply).
Event.Type: text (Text), tool_call and tool_result (Step), handoff (Step.HandoffTo, Agent = new agent), done (Result), error (Err).
Tools of the same round run in parallel; the order of results in the history is the order the model asked for them. A handoff swaps the system message, keeps the history and continues the loop with the target agent.
Composition#
func (a *Agent) AsTool(cli *Client, description string) *Tool // {"input": "..."} → text
func Parallel(ctx, cli, input string, agents ...*Agent) ([]*Result, error)
func Chain(ctx, cli, input string, agents ...*Agent) (*Result, error)Parallel returns in the agents' order and propagates the first error; Chain passes one's Output as the next one's input.
Chat over HTTP#
func Serve(c *trilha.Ctx, cli *Client, agent *Agent) error
func (o ServeOpts) Serve(c *trilha.Ctx, cli *Client, agent *Agent) errorServe is the route half of ui.Chat: it reads the message, runs the agent and answers the request.
// app/api/chat/route.go
func POST(c *trilha.Ctx) error {
return ai.ServeOpts{HTML: ui.ChatHTML}.Serve(c, client, assistant)
}The request carries {"message": "…", "history": [...]} as JSON, or a message form field. A {"messages": [...]} list is also read: the last user turn is the message and everything before it is the history.
When the client asks for Accept: text/event-stream, the answer is a stream of named events — the Stream of the framework, with the contract fixed:
| Event | Data |
|---|---|
text | the piece of text, as it is |
tool_call, tool_result, handoff | {agent, tool, call_id, arguments, output, to, error} |
done | {agent, output, html, history, usage} |
error | {message} |
Without the header the whole answer comes at once — the request that arrives when JavaScript is not there. That is the done payload as a JSON body, or whatever ServeOpts.Page renders.
Field of ServeOpts | What it does |
|---|---|
MaxHistory | how many past messages travel back into the model (default ai.DefaultServeHistory, 40; negative keeps none). The history comes from the browser, so the ceiling is the server's |
MaxInput | the largest request body accepted (default ai.DefaultServeInput, 256 KB) |
HTML | renders the finished answer for the browser; ui.ChatHTML is the one that matches ui.Chat. Without it the answer stays text |
Page | answers a request that did not ask for a stream, so the app can render the page with the message in it |
Context | receives the ctx.* fields the page sent with the message — the id of the record that is open, the route the visitor is on — and what it returns goes in front of the history |
Context is where ui.ChatOpts.Context lands, by both paths: the JSON the script sends and the hidden fields of a form that submitted on its own.
ai.ServeOpts{Context: func(c *trilha.Ctx, fields map[string]string) []ai.Message {
return []ai.Message{{Role: "user", Content: "The open invoice is " + fields["invoice"] + "."}}
}}The fields come from the request, so they are what the visitor sent and not what the server knows: check what an id gives access to there, the same way a query parameter is checked.
A failure before the first byte is an ordinary error (a problem with its status). After it, the status line is gone: the failure travels as an error event and the stream closes.
The history is the app's — the framework keeps no chat session.
Voice#
The same protocol transcribes and speaks. Two methods, the same Client, the same *Error when the provider refuses.
func (c *Client) Transcribe(ctx context.Context, r io.Reader, o TranscribeOpts) (Transcript, error)
func (c *Client) Speak(ctx context.Context, text string, o SpeakOpts) (io.ReadCloser, error)Transcribe posts r as multipart/form-data to /audio/transcriptions and answers with a Transcript (Text, and Language and Duration when the format is "verbose_json").
Field of TranscribeOpts | What it does |
|---|---|
Model | the transcription model: TRILHA_AI_TRANSCRIBE_MODEL, then "whisper-1" |
Language | ISO-639-1 of what was said; empty leaves the detection to the provider |
Prompt | spells the names and acronyms the model would otherwise get wrong |
Format | the response_format: "json" (default), "verbose_json", "text" |
Filename | the name the provider sees; default "audio.webm" |
MaxBytes | the most read from r; default 25 MB. A longer recording is an *Error with Status 413 and no request leaves |
Speak posts {model, input, voice, response_format, speed} to /audio/speech and returns the audio as it streams in; the caller closes the body. SpeakOpts.ContentType() is the media type of Format — "audio/mpeg" for the default "mp3" — which is what the route serves it as, so ui.Audio plays it.
Field of SpeakOpts | What it does |
|---|---|
Model | the speech model: TRILHA_AI_SPEECH_MODEL, then "tts-1" |
Voice | the provider's voice name; default "alloy" |
Format | "mp3" (default), "opus", "aac", "flac", "wav", "pcm" |
Speed | multiplies the pace (0.25 to 4); zero leaves the provider's 1 |
// app/api/voice/route.go
up, err := c.File("audio", trilha.FileRules{Accept: []string{"audio/*", "video/webm"}})
if err != nil {
return err
}
defer up.Close()
t, err := client.Transcribe(c.Context(), up, ai.TranscribeOpts{Language: "en", Filename: up.Name})
// app/api/voice/speech/route.go
body, err := client.Speak(c.Context(), c.Query("text"), ai.SpeakOpts{Voice: "nova"})
if err != nil {
return err
}
defer body.Close()
c.Header("Cache-Control", "private, no-store")
return c.Inline("answer.mp3", body, ai.SpeakOpts{}.ContentType())The recording comes from ui.Recorder, and the whole round trip — record, transcribe, answer, speak — is the voice section of the chapter.