3.1. Tools
In one glance
- You will: Read the contracts behind the agent’s reads and writes, delete one line of the write guard to see what it holds back, then build a read tool of your own.
- You need: The first-agent checkpoint passing. Every check here is model-free; only the exercise’s closing gate needs a running model.
- Time: about 40 minutes, hands-on.
Why an agent’s write path needs more than an instruction
A tool is a typed Go function the runtime executes when the model asks for it. ADK derives the JSON declaration the model sees from the argument struct, so schema and code cannot drift apart.
Tools exist for two reasons, and this page teaches both. A model with no tools answers only from training data, so it can say nothing about a service that started failing this morning. And once a tool can change state, what separates a paragraph from an event is code, not prompt text: a rule permitting a restart only once the cause is understood is advice a 4B model may decline, while the same rule checked in Go cannot be routed around.
This page reads both contracts, then asks you to write one. A read is typed, bounded, cancellable, and classifies its failures instead of flattening them. A write needs a confirmation, a named approver, a bounded rationale, and the audit row in the same transaction as the change. The worked instance is restart_service against the seeded inventory service.
Start where the answers are cheapest — no model, no network:
cd agents/go
go test ./toolsok github.com/MLOps-Courses/agentops-open-course/agents/go/tools 2.496sA green suite proves the guard holds, not what it holds back. The approval check in agents/go/tools/action.go refuses on two conditions: a confirmation object missing entirely, and one that is present but was never confirmed. Delete the second so only the first survives, and predict what the suite reports:
// simplified — the weakened check, for one test run only
confirmation := ctx.ToolConfirmation()
if confirmation == nil {
return approval{}, errors.New("the action has not been confirmed")
}--- FAIL: TestTheHandlerRechecksConfirmationItself (0.68s)
action_test.go:678: Error = "", want it to contain "has not been confirmed"
action_test.go:680: service status = "operational", want the refused action to have changed nothing
FAIL
FAIL github.com/MLOps-Courses/agentops-open-course/agents/go/tools 1.793s
FAILRead the two reported lines together. The error came back empty, so no refusal happened, and the service is operational: a call nobody approved changed state. One boolean was the whole distance between the model’s proposal and that change. Restore it with git restore -- agents/go/tools/action.go and watch the suite go green.
What a good read tool is: typed, bounded, and cancellable
A read tool is a parser with a deadline. That means one purpose, typed arguments, bounded work, a stable JSON result, explicit failure, and the least authority its job needs. The first four are almost free in Go, because the struct is the schema.
Here is the whole get_incident handler:
func (t *Tools) runGetIncident(ctx agent.Context, args GetIncidentArgs) (GetIncidentResult, error) {
var result GetIncidentResult
err := t.guard(ctx, GetIncidentToolName, func(ctx context.Context) error {
var err error
result, err = t.readIncident(ctx, args)
return err
})
return result, err
}Almost nothing happens here, which is the point. guard wraps the call in a per-attempt deadline, a bounded retry budget, and a circuit breaker, the switch that stops retrying a dependency that stays down. readIncident does the work and returns a typed value. External input is normalized into a validated domain type once, at this boundary, and trusted afterwards: domain.NormalizeIncidentID returns either an incident id or an error, so no code further in has to wonder whether its string is real.
How that function becomes something the model can call
Nothing in the handler says what get_incident accepts, because ADK reads that off the argument struct. jsonschema.For[TArgs] infers the JSON declaration the model will see from the Go type, newTool writes a description onto every property and refuses a schema where one is missing, and functiontool.New binds that declaration to the handler:
func newTool[TArgs, TResults any](
cfg functiontool.Config, descriptions map[string]string, handler functiontool.Func[TArgs, TResults],
) (tool.Tool, error) {
input, err := jsonschema.For[TArgs](nil)
if err != nil {
return nil, fmt.Errorf("infer the %s input schema: %w", cfg.Name, err)
}
output, err := jsonschema.For[TResults](nil)
if err != nil {
return nil, fmt.Errorf("infer the %s output schema: %w", cfg.Name, err)
}
if err = describeProperties(input, descriptions); err != nil {
return nil, fmt.Errorf("document the %s arguments: %w", cfg.Name, err)
}
collapseNullable(input)
collapseNullable(output)
cfg.InputSchema, cfg.OutputSchema = input, output
built, err := functiontool.New(cfg, handler)
if err != nil {
return nil, fmt.Errorf("build the %s tool: %w", cfg.Name, err)
}
return built, nil
}The model never sees Go. It sees that JSON, so an argument’s struct field and its one-line description are the whole contract between the two — which is why an undocumented property is refused at construction rather than discovered as a model that keeps guessing. Registering a tool then costs one table entry. Here is the guarded one, which carries the flag that makes ADK pause for a human:
{&agentTools.restartService, func() (tool.Tool, error) {
return newTool(functiontool.Config{
Name: RestartServiceToolName,
Description: restartServiceDescription,
RequireConfirmation: true,
}, map[string]string{
"name": "The service to restart, e.g. " + vocabulary.Services.Inventory + ".",
}, agentTools.runRestartService)
}},The public read surface is six tools: list_incidents, get_incident, get_service_status, search_service_logs, get_runbook, and search_runbooks. agents/go/tools constructs the incident, service, and log reads plus the two guarded actions; agents/go/memory constructs the runbook and long-term-memory tools; agents/go/compose chooses the exact set each entrypoint gets. The long-term memory helpers are separate capabilities and are deliberately not part of that six, which is why widening the remote allowlist in 3.3. MCP cannot pull them along by accident.
Every blocking read runs under a configured context deadline (AGENT_TOOL_TIMEOUT_S, 30 seconds by default). When it expires the call returns a stable failure instead of eating the rest of the turn. What that deadline buys differs by dependency, and the difference is worth carrying rather than rounding off: the three database reads (list_incidents, get_incident, get_service_status) hand the context to the driver, so an expired deadline aborts a query already in flight, while the three filesystem reads (search_service_logs, get_runbook, search_runbooks) go through os.ReadFile, which takes no context — there the deadline decides whether each read is started and cannot preempt one already blocked on a stalled mount. agents/go/tools/read.go and agents/go/memory/knowledge.go say so at the functions, because a bound that quietly holds in one place and not another is the kind of thing an operator should learn from the code rather than from an incident.
Deadlines and retries fix a blip. They make a sustained outage worse: if every read pays the full retry budget before failing, the agent turns one broken dependency into a turn that takes minutes. The breaker prevents that: after a run of consecutive failures it opens, calls fail fast, and after a cooldown exactly one probe is admitted to test whether the dependency came back. It is off by default; turn it on with AGENT_CIRCUIT_BREAKER_ENABLED=true, and tune it with AGENT_CIRCUIT_FAILURE_THRESHOLD and AGENT_CIRCUIT_RESET_TIMEOUT_S. Writes never go through any of it, because an automatic retry can cross an unknown commit boundary and apply the same change twice.
Two more properties matter once results reach a model.
Failures are classified, not flattened. An expected domain failure — no such incident, unknown service, a limit of zero — comes back as a stable error field the model can relay to whoever asked. An infrastructure or programming failure comes back as a wrapped Go error, so database corruption, cancellation, and violated invariants stay visible in logs and traces instead of being smoothed into “I couldn’t find that”. Nothing is silently repaired: a seed row that fails validation returns an error, not a plausible guess.
Results are treated as data, all the way to the prompt. Before a tool result reaches the model, app-wide policy recursively redacts concrete PII and credentials, and untrusted tool output is neutralized and spotlighted: wrapped in markers that tell the model the enclosed text is data, not instruction. An instruction hiding inside a log line therefore stays marked as content. The one narrow carve-out is locally constructed skill content, which 3.2. Skills explains and 4.5. Guardrails enforces.
Those properties argue against the tool everyone reaches for first. One generic query_database would give the model a bigger schema, broader authority, more ways to construct something unsafe, and an audit trail where every event says “ran SQL”. Small domain tools produce small schemas, legible audit rows, deterministic tests, and an allowlist you can read out loud.
How a guarded write requires confirmation and a named approver
restart_service and resolve_incident change state. ADK marks both as requiring confirmation, so calling one does not run it: it creates an approval request and pauses. That is the framework’s half. The handler’s half is to distrust it and check everything again in the last instruction before the write:
func (t *Tools) validatedApproval(ctx agent.Context) (approval, error) {
if ctx == nil {
return approval{}, errors.New("the action must run through an ADK confirmation flow")
}
confirmation := ctx.ToolConfirmation()
if confirmation == nil || !confirmation.Confirmed {
return approval{}, errors.New("the action has not been confirmed")
}
// Session() is one of the accessors ADK stubs out inside a tool context, so
// the identity is read from SessionID()/UserID(), which are not.
identities := []struct{ label, value string }{
{"approver identity", ctx.UserID()},
{"session id", ctx.SessionID()},
{"invocation id", ctx.InvocationID()},
}
var missing []string
for _, identity := range identities {
if strings.TrimSpace(identity.value) == "" {
missing = append(missing, identity.label)
}
}
if len(missing) > 0 {
// All of them, not the first: an operator fixing a broken client wants the
// whole list in one round trip.
return approval{}, fmt.Errorf("the confirmed action is missing %s", strings.Join(missing, ", "))
}
approvedBy := strings.TrimSpace(ctx.UserID())
networkPrincipal, networkState := principal.Network(ctx)
switch networkState {
case principal.NetworkUnauthenticated:
return approval{}, errors.New("a network action requires an authenticated principal in addition to confirmation")
case principal.NetworkAuthenticated:
if networkPrincipal.Subject() != approvedBy {
return approval{}, errors.New("the authenticated principal does not own the invocation")
}
}
rationale := rationaleFrom(confirmation.Payload)
if rationale == "" {
return approval{}, errors.New("the approval carried no rationale")
}
if !fitsRationale(rationale) {
return approval{}, fmt.Errorf(
"the approval rationale exceeds %d characters", domain.MaxAuditRationaleLength,
)
}
// Redaction runs before the row is written, because the audit trail is
// append-only: a credential that lands there cannot be edited out afterwards.
rationale = t.redact(rationale)
// And the bound is re-checked afterwards, because a redactor rewrites rather
// than deletes — "<EMAIL_ADDRESS>" is longer than most addresses it replaces.
if !fitsRationale(rationale) {
return approval{}, fmt.Errorf(
"the redacted approval rationale exceeds %d characters", domain.MaxAuditRationaleLength,
)
}
return approval{
approvedBy: approvedBy,
rationale: rationale,
sessionID: strings.TrimSpace(ctx.SessionID()),
invocationID: strings.TrimSpace(ctx.InvocationID()),
}, nil
}Every requirement is re-verified here: the confirmation exists and is confirmed, an approver identity, a session id, and an invocation id are all present, a network action also carries an authenticated principal that owns that invocation, and a rationale exists, fits the audit bound, and is redacted before it can reach an append-only row. Any missing piece returns a refusal a human can read. This is the function you weakened at the top of this page, and why a forged context or a direct Go call cannot walk around the rule the model-facing tool advertises. A session id minted synthetically for a remote caller, as the network surface later in this chapter does, scopes reads; it is not an approver.
What a human sees before approving is part of the contract: the action, the validated target, the reason, and the verified actor — no credentials, no unrelated conversation. Approval is specific to one invocation. A blanket “allow writes” switch is a different feature and is not implemented.
Follow a guarded INC-002 inventory restart through the whole path: seven ordered steps for the call, then one rule around them. It mutates only the disposable SQLite simulation under agents/go/.state and restarts no Kubernetes workload anywhere.
- After reading
INC-002, its logs, and theservice-downrunbook, the model proposesrestart_service(name="inventory"). Prose cannot create an action; only the tool call does. - The typed Go entry point parses the service slug, checks the target exists, and refuses immediately if
AGENT_WRITES_DISABLED=true. - ADK pauses before the handler runs and presents the exact proposed action for confirmation.
- Confirmed execution still requires the approver, session id, invocation id, and bounded redacted rationale above; a network action also requires the matching authenticated principal.
- One SQLite transaction changes the simulated service state and appends its audit row — or rolls both back together.
- Replaying the same invocation, action, and target returns the original audit row instead of mutating twice.
- The agent re-reads the service and the incident. Only those fresh observations can support a recovery claim.
- Around those seven, one operational rule: during an incident of the agent’s own, the write freeze preserves read access, and backup and restore stop writers and use the journaled path rather than copying live SQLite files.
Step five is the one worth arguing about. The state change and the audit row commit or roll back together, so there is no successful action nobody wrote down, and no audit claim for an action that never landed. SQLite triggers reject updates and deletes on that table, which is why the course calls the log append-only rather than immutable: an administrator with file access can still alter the database.
Step six is why idempotency is not permission to retry. The audit key makes a re-delivered write safe, but the client never retries automatically, because after a transport failure the caller cannot know which side of the commit the first attempt fell on.
The disposable copy under .state makes this practicable: the committed dataset stays immutable, a runtime generation is published at the write-owning boundary, and mise run data:reset puts you back to a known start.
Four focused suites cover that lifecycle, still without a model:
cd agents/go
go test ./tools ./memory ./resilience ./data./tools covers the typed contracts and the approval check you just broke, ./resilience the deadline, retry, and breaker behaviour, ./data the transaction carrying mutation and audit row together, and ./memory the runbook reads. A green run says something about this simulation and nothing about a production service — see 0.2. Evidence.
Your turn: prototype a get_oncall_schedule read tool
You have read two contracts. Now write one, offline and inside the Go module. Predict which case will be hardest to fail for the right reason: a tool that ignores its context still returns a correct-looking result.
- Mode:
keep— this is the tool you carry into the rest of the chapter. - Goal: add one bounded typed read without widening database or filesystem authority.
- Files to touch: a focused source file and its tests under
agents/go/tools; composition only after the tool contract passes. - Preflight: a clean focused diff, and
cd agents/go && go test ./toolsgreen before you start. - Steps: define typed argument and result structs in a focused file — those two types are the schema the model will see. Parse the external service string into a validated domain value once, at the boundary, instead of passing a raw string inward and re-checking it everywhere. Bound the returned schedule. Register it with one more
newTool(functiontool.Config{…}, map[string]string{…}, handler)entry in the build table, giving every argument a description; leave one out and the constructor refuses the tool rather than shipping a field the model cannot interpret. Use a fake or temporary fixture rather than committed runtime state. Add table-driven success, missing, invalid, and cancellation cases. Then decide, explicitly, whether the tool belongs in the local and remote read allowlists. - Gate that proves completion:
cd agents/go && go test -race ./tools ./composepasses, and your cancellation case fails if you remove the guard wrapper. Then watch a model reach for it: runcd agents/go && mise run web, openhttp://localhost:8002, selectagentops_agent, ask a question only your tool can answer, and confirm the Events view shows afunctionCallnaming it before the answer arrives. About 5 minutes. Usemise run webrather thanmise run run— as 2.5’s own table says, the console prints the answer without the tool calls behind it. - Final state: the diff contains only the intended tool, its tests, and any reviewed allowlist change — no generated runtime state, no generic file or database escape hatch.
What you can do now
- You can name
confirmation.Confirmedas the one boolean between a proposal and a service leftoperational. - You can name the six reads, the two guarded writes, and the authority each one deliberately lacks.
- You can explain why a deadline needs a circuit breaker behind it, and why write idempotency is not permission to retry.
- You can add a typed read whose struct is the schema, and a cancellation case that fails without the guard.
You can now state who must be present, what must be true, and what gets written down before a proposal becomes an event.
Continue to 3.2. Skills, where the agent gets procedures instead of tools.