Skip to content
Trilha
Chapters

Reference

ai

OpenAI-compatible client, tools, agents, handoffs and composition.

import "github.com/emersonjoe/trilha/ai" — no external dependencies.

Client#

Field / functionRole
NewFromEnv() *Clientreads 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 stringdirect configuration
Headers map[string]stringextra headers (OpenRouter, Azure...)
HTTPClient *http.ClientHTTP 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) errorchunked 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)) ToolFunc

schema == nil means "no arguments". Errors and panics from the function become text for the model (error: ...) and show up in Step.Err.

Agent#

FieldRole
Nameidentifies the agent in Step.Agent and in handoffs (transfer_to_<slug>)
Instructionssystem message
Modeloverrides the client's model
Tools []*Tooltools
Handoffs []*Agentagents this one may transfer the conversation to
MaxTurnslimit of model calls per Run (default 10; exceeded → ErrMaxTurns)
Temperature *float64, ResponseFormatpassed 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) error

Serve 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:

EventData
textthe 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 ServeOptsWhat it does
MaxHistoryhow 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
MaxInputthe largest request body accepted (default ai.DefaultServeInput, 256 KB)
HTMLrenders the finished answer for the browser; ui.ChatHTML is the one that matches ui.Chat. Without it the answer stays text
Pageanswers a request that did not ask for a stream, so the app can render the page with the message in it
Contextreceives 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 TranscribeOptsWhat it does
Modelthe transcription model: TRILHA_AI_TRANSCRIBE_MODEL, then "whisper-1"
LanguageISO-639-1 of what was said; empty leaves the detection to the provider
Promptspells the names and acronyms the model would otherwise get wrong
Formatthe response_format: "json" (default), "verbose_json", "text"
Filenamethe name the provider sees; default "audio.webm"
MaxBytesthe 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 SpeakOptsWhat it does
Modelthe speech model: TRILHA_AI_SPEECH_MODEL, then "tts-1"
Voicethe provider's voice name; default "alloy"
Format"mp3" (default), "opus", "aac", "flac", "wav", "pcm"
Speedmultiplies 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.