Build a Guardrailed Local AI Agent

Build a local AI agent that analyzes SSH evidence with policy guardrails.

Introduction

30 Second Summary

When a server looks unhealthy, the fastest fix can also be the riskiest one. A useful assistant should explain what it sees without turning a guess into a disruptive action.

In this project, you will build a Go command-line operations analyst on control01 powered by Gemma 3 1B through Ollama. It will turn live evidence from infra01 into structured model responses guarded by a default-deny policy.

What You'll Build

You will choose a host scenario and watch the terminal show live evidence, a structured summary, a blocked restart request, and a saved decision record.

By the end of this project, you'll have:

  • A structured local AI reply from gemma3:1b that proves your Go program can validate every required decision field.
  • Two live infrastructure scenarios that gather allowlisted evidence from infra01 over SSH before showing the model's analysis.
  • An auditable handoff where you can compare an unreviewed preview with a guarded result, inspect timestamped JSON decision artifacts, and continue from a named Git checkpoint.
  • Secret Mission: Add a third configuration-driven scenario for infra01 without changing the Go agent. Then prove that its unsafe restart request remains blocked.

What do I need before I start?

You need shell access to control01, sudo access there, internet access for downloads, and SSH reachability to infra01.

Go 1.26.10 and Git 2.52.0 are already available on control01.

Before We Start

Before any hands-on work, commit to the safety boundary of the Go operations agent you are building on control01. The agent will collect read-only evidence from infra01 for local-model analysis without executing remediation.

Prepare the Local AI Workspace

Your agent needs a predictable Go workspace before it can call the local model or inspect a host. This step confirms the installed tools and creates the repository contract.

You will also prove that Ollama responds locally and that SSH can reach infra01 without an interactive password prompt. Those checks keep later failures focused on your code.

In this step, get ready to:
  • Verify Go and Git on control01.
  • Create the Go module and project directories.
  • Confirm local Ollama and remote SSH access.
Verify the installed tools
  • Check the installed Go and Git versions by running:
go version
git --version

✔️ I see Go 1.26.10

You should see Go 1.26.10 and Git 2.52.0. Your toolchain is ready.

ⓧ I see an older Go version

An older Go version can compile different language features. Ask your lab administrator to restore Go 1.26.10 on control01 before continuing.

ⓧ Command not found

A missing command means the prepared lab toolchain is incomplete. Ask your lab administrator to restore Go and Git on control01.

Create the Go workspace
  • Create the project directories, initialize the Go module, and start the Git repository by running:
cd ~
mkdir -p local-ai-ops-agent/{cmd/agent,config,contracts,handoff,runs}
cd local-ai-ops-agent
go mod init local-ai-ops-agent
git init -b main

What does this setup create?

  • go mod init creates go.mod as the module definition.
  • git init creates local version history without contacting a remote service.
  • The named directories separate the Go entry point, configuration, contracts, handoff files, and runtime artifacts.
  • Confirm the workspace structure by running:
find . -maxdepth 2 -type d | sort

You should see cmd/agent, config, contracts, handoff, and runs under the repository.

Workspace missing a directory?

Run the setup commands from your home directory and check that the repository is named local-ai-ops-agent. Help me repair the Go workspace structure.

Confirm local and remote access
  • Check the local Ollama API by running:
curl -s http://localhost:11434/api/tags

You should see JSON containing the local model list. Confirm that gemma3:1b appears before continuing.

  • Test non-interactive access to infra01 by running:
ssh -o BatchMode=yes infra01 'printf "SSH_OK\n"'

You should see SSH_OK with no password prompt. Your Go workspace can now reach every service used in this project.

Access check failing?

  • Confirm the Ollama service is running on control01 if the local API does not respond.
  • Confirm your SSH configuration resolves infra01 if the remote check fails.
  • Help me diagnose the failed preflight check.

Your Go module, local model, and infrastructure connection are ready. Next, you will turn that clean setup into a validated local AI reply.

Get the First Local AI Reply

Your workspace can reach infra01 without interactive prompts. Your local model is also ready to accept requests.

Now your Go program needs to send a structured request to Ollama’s local API. A local smoke test isolates that boundary before live host evidence adds complexity.

The response is decoded into typed Go structs before the agent accepts it. That turns the model’s text into data the later policy layer can inspect.

In this step, get ready to:
  • Define the Go types for evidence and model decisions.
  • Call Ollama with a JSON schema.
  • Run a smoke test that validates the reply.
Define the agent types
  • Create cmd/agent/main.go in your editor.
  • Add the package, imports, and project constants with this code:
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"strings"
	"time"
)

// Keep the model and endpoint fixed for reproducible local runs.
const (
	model         = "gemma3:1b"
	ollamaChatURL = "http://localhost:11434/api/chat"
)

What does this foundation provide?

  • The standard library handles HTTP, JSON, terminal output, and timeouts.
  • model fixes the model used by every decision artifact.
  • ollamaChatURL keeps every request on control01.
  • Add the typed request and response contracts directly below the constants:
// Message represents one turn in an Ollama chat request.
type Message struct {
	Role    string `json:"role"`
	Content string `json:"content"`
}

// Evidence records one read-only observation.
type Evidence struct {
	Command  string `json:"command"`
	ExitCode int    `json:"exit_code"`
	Stdout   string `json:"stdout"`
	Stderr   string `json:"stderr"`
}

// ModelDecision is the only model output the agent accepts.
type ModelDecision struct {
	Summary             string   `json:"summary"`
	Observations        []string `json:"observations"`
	RecommendedActionID string   `json:"recommended_action_id"`
	Reasoning           string   `json:"reasoning"`
	Confidence          string   `json:"confidence"`
}

Why use typed structs?

Go’s JSON tags map snake-case fields from the API onto typed fields. The structs also make missing or malformed values easier to reject.

  • Add the Ollama envelope types below ModelDecision:
// ChatRequest carries the prompt and schema to Ollama.
type ChatRequest struct {
	Model    string         `json:"model"`
	Messages []Message      `json:"messages"`
	Stream   bool           `json:"stream"`
	Format   map[string]any `json:"format"`
	Options  map[string]any `json:"options"`
}

// ChatResponse contains the completed assistant message.
type ChatResponse struct {
	Message Message `json:"message"`
	Done    bool    `json:"done"`
}
  • Save cmd/agent/main.go.
  • Format the partial file by running:
gofmt -w cmd/agent/main.go

Open the file again. You should see aligned struct fields and tab-based indentation produced by gofmt.

Constrain the model response
  • Add the schema and validator below the response types:
// decisionSchema constrains Ollama to the five accepted fields.
func decisionSchema() map[string]any {
	return map[string]any{
		"type": "object",
		"properties": map[string]any{
			"summary": map[string]any{"type": "string"},
			"observations": map[string]any{
				"type": "array", "items": map[string]any{"type": "string"},
			},
			"recommended_action_id": map[string]any{
				"type": "string", "enum": []string{"inspect_logs"},
			},
			"reasoning":  map[string]any{"type": "string"},
			"confidence": map[string]any{"type": "string", "enum": []string{"low", "medium", "high"}},
		},
		"required": []string{"summary", "observations", "recommended_action_id", "reasoning", "confidence"},
		"additionalProperties": false,
	}
}

How is the reply constrained?

Ollama accepts a JSON schema in the request’s format field. The schema requires five fields and rejects extra fields.

  • Add the HTTP helper below decisionSchema():
// sendChat posts one non-streaming request to the local API.
func sendChat(payload ChatRequest) (ChatResponse, error) {
	body, err := json.Marshal(payload)
	if err != nil {
		return ChatResponse{}, err
	}
	client := &http.Client{Timeout: 120 * time.Second}
	response, err := client.Post(ollamaChatURL, "application/json", bytes.NewReader(body))
	if err != nil {
		return ChatResponse{}, err
	}
	defer response.Body.Close()
	if response.StatusCode != http.StatusOK {
		return ChatResponse{}, fmt.Errorf("Ollama returned %s", response.Status)
	}
	var chat ChatResponse
	if err := json.NewDecoder(response.Body).Decode(&chat); err != nil {
		return ChatResponse{}, err
	}
	return chat, nil
}
  • Add the model call below sendChat():
// callOllama converts evidence into a validated decision.
func callOllama(goal string, evidence []Evidence) (ModelDecision, error) {
	promptData := map[string]any{"goal": goal, "evidence": evidence}
	prompt, err := json.Marshal(promptData)
	if err != nil {
		return ModelDecision{}, err
	}
	payload := ChatRequest{
		Model: model,
		Messages: []Message{
			{Role: "system", Content: "You are a read-only Linux operations analyst. Return only grounded JSON."},
			{Role: "user", Content: string(prompt)},
		},
		Stream: false,
		Format: decisionSchema(),
		Options: map[string]any{"temperature": 0},
	}
	chat, err := sendChat(payload)
	if err != nil {
		return ModelDecision{}, err
	}
	var decision ModelDecision
	decoder := json.NewDecoder(strings.NewReader(chat.Message.Content))
	decoder.DisallowUnknownFields()
	if err := decoder.Decode(&decision); err != nil {
		return ModelDecision{}, err
	}
	return decision, nil
}

What does the model call enforce?

  • The prompt carries only the stated goal and evidence.
  • stream: false returns one complete JSON response.
  • DisallowUnknownFields() rejects fields outside the Go contract.
Run the smoke test
  • Add the smoke test and command entry point below callOllama():
// smokeTest proves the local API works without contacting infra01.
func smokeTest() error {
	evidence := []Evidence{{
		Command: "none", ExitCode: 0,
		Stdout: "Local smoke test only. No host was contacted.",
	}}
	decision, err := callOllama("Confirm the local model returns structured fields.", evidence)
	if err != nil {
		return err
	}
	result, err := json.MarshalIndent(decision, "", "  ")
	if err != nil {
		return err
	}
	fmt.Println("Smoke test passed")
	fmt.Printf("Model: %s\n%s\n", model, result)
	return nil
}

// main exposes the smoke command used in this step.
func main() {
	if len(os.Args) != 2 || os.Args[1] != "smoke" {
		fmt.Fprintln(os.Stderr, "Usage: go run ./cmd/agent smoke")
		os.Exit(1)
	}
	if err := smokeTest(); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}

What does the smoke test prove?

The smoke command uses synthetic evidence, so a successful result proves only the Go-to-Ollama boundary. The next step replaces that synthetic record with live infra01 evidence.

  • Save and format cmd/agent/main.go by running:
gofmt -w cmd/agent/main.go

Before you run the program, do you think the reply will contain all five decision fields?

  • Run the smoke test from ~/local-ai-ops-agent by running:
go run ./cmd/agent smoke

You’ll see Smoke test passed followed by Model: gemma3:1b and a formatted decision with all five fields.

Smoke test not completing?

  • Run gofmt again if Go reports a syntax error near a pasted block.
  • Confirm Ollama is running if the HTTP request cannot reach the local endpoint.
  • Help me troubleshoot the Go smoke test.

Your Go agent now gets a typed reply from the local model. Next, you will replace the synthetic record with allowlisted evidence from infra01.

Feed the Agent Live Host Evidence

Your smoke test proved that the local Ollama API returns a typed Go decision. That test used synthetic evidence from the program itself.

The agent now needs current evidence from infra01. You will define two scenarios and collect only their allowlisted commands over SSH.

The preview remains read-only and unreviewed. It shows what the model recommends before the deterministic policy arrives in the next step.

In this step, get ready to:
  • Define two infra01 evidence scenarios.
  • Collect only allowlisted remote evidence.
  • Preview a structured recommendation without executing it.
Define the infrastructure scenarios

A scenario binds one goal to a fixed host and a small command list. Configuration lets you add cases without embedding each goal in the Go control flow.

  • Create config/scenarios.json inside ~/local-ai-ops-agent.
  • Add the two infrastructure scenarios with this configuration:
{
  "schema_version": "1.0",
  "scenarios": {
    "infra_load": {
      "title": "Infrastructure load snapshot",
      "host": "infra01",
      "goal": "Summarize uptime and load evidence.",
      "commands": ["uptime"],
      "requested_action_id": "inspect_processes"
    },
    "infra_memory": {
      "title": "Infrastructure memory review",
      "host": "infra01",
      "goal": "Summarize memory evidence and assess a restart request.",
      "commands": ["free"],
      "requested_action_id": "restart_service"
    }
  }
}

What does this catalog define?

  • infra_load collects uptime for a read-only process-inspection recommendation.
  • infra_memory collects free while asking the later policy to assess a restart request.
  • Both scenarios target infra01.
  • Save config/scenarios.json.
  • Confirm that the JSON parses by running:
go run ./cmd/agent smoke

You should still see the successful smoke result. The existing local model path remains intact while the scenario catalog waits for the next code chunk.

Collect allowlisted evidence

Go’s exec.CommandContext runs the SSH client without invoking a shell. A timeout turns an unreachable host into a bounded failure.

  • In cmd/agent/main.go, find the import block.
  • Add context and os/exec to the import block.
import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"os/exec"
	"strings"
	"time"
)
  • Add the scenario types below Evidence:
// Scenario describes one configuration-driven evidence request.
type Scenario struct {
	Title             string   `json:"title"`
	Host              string   `json:"host"`
	Goal              string   `json:"goal"`
	Commands          []string `json:"commands"`
	RequestedActionID string   `json:"requested_action_id"`
}

// ScenarioCatalog holds every declared scenario.
type ScenarioCatalog struct {
	Scenarios map[string]Scenario `json:"scenarios"`
}

// Keep remote evidence collection deliberately small.
var allowedEvidence = map[string]bool{"uptime": true, "free": true}
  • Add the scenario loader below decisionSchema():
// loadScenario reads one declared scenario from disk.
func loadScenario(id string) (Scenario, error) {
	data, err := os.ReadFile("config/scenarios.json")
	if err != nil {
		return Scenario{}, err
	}
	var catalog ScenarioCatalog
	if err := json.Unmarshal(data, &catalog); err != nil {
		return Scenario{}, err
	}
	scenario, ok := catalog.Scenarios[id]
	if !ok {
		return Scenario{}, fmt.Errorf("unknown scenario: %s", id)
	}
	return scenario, nil
}
  • Add the bounded SSH runner below loadScenario():
// runRemote collects one allowlisted command without a shell.
func runRemote(host, command string) (Evidence, error) {
	if !allowedEvidence[command] {
		return Evidence{}, fmt.Errorf("evidence command is not allowlisted: %s", command)
	}
	ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
	defer cancel()
	cmd := exec.CommandContext(ctx, "ssh", "-o", "BatchMode=yes", host, command)
	var stdout, stderr bytes.Buffer
	cmd.Stdout = &stdout
	cmd.Stderr = &stderr
	err := cmd.Run()
	exitCode := 0
	if err != nil {
		exitCode = 1
	}
	if ctx.Err() == context.DeadlineExceeded {
		exitCode = 124
		stderr.WriteString("SSH evidence collection timed out")
	}
	return Evidence{
		Command: command, ExitCode: exitCode,
		Stdout: strings.TrimSpace(stdout.String()),
		Stderr: strings.TrimSpace(stderr.String()),
	}, nil
}

What boundary does the runner enforce?

  • The allowlist rejects commands outside uptime and free.
  • BatchMode=yes prevents an interactive password prompt.
  • The 20-second context stops an unreachable host from holding the agent indefinitely.
  • Save and format cmd/agent/main.go by running:
gofmt -w cmd/agent/main.go
Run the live preview
  • Add the preview workflow below runRemote():
// previewScenario gathers live evidence without policy approval.
func previewScenario(id string) error {
	scenario, err := loadScenario(id)
	if err != nil {
		return err
	}
	evidence := make([]Evidence, 0, len(scenario.Commands))
	for _, command := range scenario.Commands {
		item, err := runRemote(scenario.Host, command)
		if err != nil {
			return err
		}
		evidence = append(evidence, item)
	}
	decision, err := callOllama(scenario.Goal, evidence)
	if err != nil {
		return err
	}
	output, _ := json.MarshalIndent(map[string]any{
		"scenario": id, "evidence": evidence, "analysis": decision,
	}, "", "  ")
	fmt.Printf("UNREVIEWED\n%s\nExecution performed: false\n", output)
	return nil
}
  • Replace main() with the command router below:
// main routes smoke and preview commands.
func main() {
	var err error
	switch {
	case len(os.Args) == 2 && os.Args[1] == "smoke":
		err = smokeTest()
	case len(os.Args) == 3 && os.Args[1] == "preview":
		err = previewScenario(os.Args[2])
	default:
		fmt.Fprintln(os.Stderr, "Usage: go run ./cmd/agent smoke|preview <scenario>")
		os.Exit(1)
	}
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}
  • Save and format the completed preview code by running:
gofmt -w cmd/agent/main.go

Before you run the preview, do you think the terminal will show live memory values from infra01?

  • Run the infrastructure memory preview by running:
go run ./cmd/agent preview infra_memory

You should see UNREVIEWED, live free output from infra01, structured analysis, and Execution performed: false.

Live preview failing?

  • Confirm batch-mode SSH still reaches infra01 if evidence collection fails.
  • Confirm both configuration commands exist in allowedEvidence.
  • Help me troubleshoot the live Go preview.

Your Go agent now turns live infra01 evidence into an unreviewed recommendation. Next, you will place deterministic policy between that recommendation and any future executor.

Add Default-Deny Guardrails

Your preview combines live host evidence with structured model analysis. The requested action still reaches the screen without a deterministic safety review.

This step inserts a default-deny policy between analysis and any future executor. Every guarded run also saves the evidence and policy result without executing remediation.

In this step, get ready to:
  • Define allowed and blocked action IDs.
  • Create the decision artifact contract.
  • Run a guarded scenario that blocks a restart request.
Define the policy and contract

The model produces probabilistic analysis. The policy performs an exact lookup and applies blocked when no explicit action entry exists.

  • Create config/policy.json inside ~/local-ai-ops-agent.
  • Add the deterministic policy with this configuration:
{
  "schema_version": "1.0",
  "default_effect": "blocked",
  "actions": {
    "inspect_processes": {
      "effect": "allowed",
      "description": "Read-only process inspection is allowed."
    },
    "inspect_memory": {
      "effect": "allowed",
      "description": "Read-only memory inspection is allowed."
    },
    "inspect_logs": {
      "effect": "allowed",
      "description": "Read-only log inspection is allowed."
    },
    "restart_service": {
      "effect": "blocked",
      "description": "A restart changes host state."
    }
  }
}

How does the policy fail closed?

Known read-only action IDs receive an allowed review result. The restart action and every unknown action remain blocked.

  • Create contracts/decision.schema.json inside the existing contracts directory.
  • Add the artifact contract with this schema:
{
  "title": "Guardrailed Local AI Operations Decision",
  "type": "object",
  "properties": {
    "schema_version": {"const": "1.0"},
    "run_id": {"type": "string"},
    "created_at": {"type": "string"},
    "scenario_id": {"type": "string"},
    "host": {"type": "string"},
    "model": {"const": "gemma3:1b"},
    "evidence": {"type": "array"},
    "analysis": {"type": "object"},
    "policy_evaluation": {"type": "object"},
    "execution_performed": {"const": false}
  },
  "required": [
    "schema_version", "run_id", "created_at", "scenario_id",
    "host", "model", "evidence", "analysis",
    "policy_evaluation", "execution_performed"
  ],
  "additionalProperties": false
}
  • Save both JSON files.
  • Confirm that the earlier preview still works by running:
go run ./cmd/agent preview infra_memory

You should still see UNREVIEWED and Execution performed: false. Creating policy files alone does not silently change the existing path.

Wire policy evaluation into Go
  • Add the policy types below ScenarioCatalog in cmd/agent/main.go:
// PolicyAction is one explicit action rule.
type PolicyAction struct {
	Effect      string `json:"effect"`
	Description string `json:"description"`
}

// Policy contains explicit rules and a fail-closed default.
type Policy struct {
	DefaultEffect string                  `json:"default_effect"`
	Actions       map[string]PolicyAction `json:"actions"`
}

// PolicyResult records the exact rule applied to an action.
type PolicyResult struct {
	ActionID string `json:"action_id"`
	Effect   string `json:"effect"`
	Reason   string `json:"reason"`
}
  • Add the policy loader and evaluator below loadScenario():
// loadPolicy reads the deterministic action rules.
func loadPolicy() (Policy, error) {
	data, err := os.ReadFile("config/policy.json")
	if err != nil {
		return Policy{}, err
	}
	var policy Policy
	err = json.Unmarshal(data, &policy)
	return policy, err
}

// evaluateAction applies an exact rule or the blocked default.
func evaluateAction(policy Policy, actionID string) PolicyResult {
	action, ok := policy.Actions[actionID]
	if !ok {
		return PolicyResult{
			ActionID: actionID, Effect: policy.DefaultEffect,
			Reason: "No explicit policy entry exists.",
		}
	}
	return PolicyResult{
		ActionID: actionID, Effect: action.Effect,
		Reason: action.Description,
	}
}

Why evaluate after the model?

An exact match returns the configured rule. A missing match receives default_effect, so a new model action cannot gain permission by accident.

  • Add the evidence collector and artifact writer below runRemote():
// collectEvidence runs every allowlisted command for a scenario.
func collectEvidence(scenario Scenario) ([]Evidence, error) {
	items := make([]Evidence, 0, len(scenario.Commands))
	for _, command := range scenario.Commands {
		item, err := runRemote(scenario.Host, command)
		if err != nil {
			return nil, err
		}
		items = append(items, item)
	}
	return items, nil
}

// writeJSON creates a run directory and stores readable JSON.
func writeJSON(path string, value any) error {
	if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
		return err
	}
	data, err := json.MarshalIndent(value, "", "  ")
	if err != nil {
		return err
	}
	return os.WriteFile(path, append(data, '\n'), 0o640)
}
  • Add path/filepath to the import block.
  • Save and format the file by running:
gofmt -w cmd/agent/main.go
Run the guarded scenario
  • Add the guarded workflow below previewScenario():
// runGuardedScenario saves evidence and policy results without execution.
func runGuardedScenario(id string) error {
	scenario, err := loadScenario(id)
	if err != nil {
		return err
	}
	evidence, err := collectEvidence(scenario)
	if err != nil {
		return err
	}
	decision, err := callOllama(scenario.Goal, evidence)
	if err != nil {
		return err
	}
	policy, err := loadPolicy()
	if err != nil {
		return err
	}
	now := time.Now().UTC()
	runID := now.Format("20060102T150405Z") + "-" + id
	artifact := map[string]any{
		"schema_version": "1.0", "run_id": runID,
		"created_at": now.Format(time.RFC3339), "scenario_id": id,
		"host": scenario.Host, "model": model, "evidence": evidence,
		"analysis": decision, "execution_performed": false,
		"policy_evaluation": map[string]any{
			"requested_action": evaluateAction(policy, scenario.RequestedActionID),
			"recommended_action": evaluateAction(policy, decision.RecommendedActionID),
		},
	}
	path := filepath.Join("runs", runID, "decision.json")
	if err := writeJSON(path, artifact); err != nil {
		return err
	}
	fmt.Printf("Requested action: %s\nExecution performed: false\nSaved: %s\n", scenario.RequestedActionID, path)
	return nil
}
  • Replace main() with the final command router:
// main routes smoke, preview, and guarded runs.
func main() {
	var err error
	switch {
	case len(os.Args) == 2 && os.Args[1] == "smoke":
		err = smokeTest()
	case len(os.Args) == 3 && os.Args[1] == "preview":
		err = previewScenario(os.Args[2])
	case len(os.Args) == 3 && os.Args[1] == "run":
		err = runGuardedScenario(os.Args[2])
	default:
		fmt.Fprintln(os.Stderr, "Usage: go run ./cmd/agent smoke|preview|run <scenario>")
		os.Exit(1)
	}
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}
  • Save and format cmd/agent/main.go by running:
gofmt -w cmd/agent/main.go

Before you run the guarded path, do you think the restart request will be allowed or blocked?

  • Run the guarded memory scenario by running:
go run ./cmd/agent run infra_memory

You should see Requested action: restart_service, Execution performed: false, and a saved path under runs/.

That is the safety boundary working: live evidence produced an auditable decision while the restart request remained outside any executor.

Guarded run failing?

  • Confirm restart_service has the blocked effect in config/policy.json.
  • Confirm path/filepath appears in the import block if Go reports an undefined name.
  • Help me troubleshoot the guarded Go run.

Your Go agent now records policy-reviewed decisions without remediation. Next, you will validate the declared scenarios and freeze a continuation checkpoint.

Create the Continuation Checkpoint

Your Go agent now turns live infra01 evidence into policy-reviewed decision artifacts without executing remediation. That proves the current safety boundary works.

A follow-on project needs a stable contract and reproducible commands. This step validates both scenarios before Git marks the exact source checkpoint.

The handoff names mon01 only as a future sequel target. It is not part of this project’s validated runtime.

In this step, get ready to:
  • Validate both guarded infrastructure scenarios.
  • Create the repository documentation and handoff manifest.
  • Commit the tracked files and create the completion tag.
Validate the Go agent

A continuation checkpoint should represent every scenario it claims to support. Static checks plus fresh runs give the handoff both source and runtime evidence.

  • Check the Go package by running:
go vet ./...

A successful check returns to the prompt without output. That confirms Go found no reportable package issues.

  • Run the infrastructure load scenario by running:
go run ./cmd/agent run infra_load

You should see Requested action: inspect_processes, Execution performed: false, and a new artifact path.

  • Run the infrastructure memory scenario by running:
go run ./cmd/agent run infra_memory

You should see Requested action: restart_service, Execution performed: false, and another artifact path.

Both declared scenarios now have fresh guarded artifacts. The execution boundary stayed closed for the allowed inspection request and the blocked restart request.

Create the handoff files

Runtime artifacts can contain host observations. The ignore file keeps those local while the Go source and contracts remain tracked.

  • Create .gitignore inside ~/local-ai-ops-agent with this content:
runs/
agent
  • Create README.md in the same repository with this content:
# Guardrailed Local AI Operations Agent

This Go command-line agent collects allowlisted evidence from infra01 over SSH, asks gemma3:1b for structured analysis through Ollama, applies deterministic policy, and never executes remediation.

Run `go run ./cmd/agent smoke` for the local model check. Run `go run ./cmd/agent run <scenario_id>` for a guarded scenario.

The next project starts from the `project-1-complete` Git tag and reads `handoff/PROJECT_STATE.json`. That sequel may add mon01 behind the same evidence and policy boundaries.
  • Create handoff/PROJECT_STATE.json with this manifest:
{
  "schema_version": "1.0",
  "project_id": "guardrailed-local-ai-ops-agent",
  "project_status": "project-1-complete",
  "checkpoint": {
    "git_tag": "project-1-complete",
    "expected_worktree": "clean"
  },
  "runtime": {
    "go": "1.26.10",
    "model": "gemma3:1b",
    "control_host": "control01",
    "target_hosts": ["infra01"]
  },
  "runtime_contract": {
    "entrypoint": "go run ./cmd/agent run <scenario_id>",
    "artifact_pattern": "runs/<run_id>/decision.json"
  },
  "validated_scenarios": ["infra_load", "infra_memory"],
  "stable_invariants": [
    "Evidence commands remain hard-coded and allowlisted.",
    "Model output is structured before policy evaluation.",
    "Unknown action IDs remain blocked by default.",
    "This project never executes remediation commands."
  ],
  "next_project": {
    "future_target": "mon01",
    "first_acceptance_test": "A blocked action never reaches an executor."
  }
}

What does the manifest preserve?

  • The runtime names Go 1.26.10 and only the current target, infra01.
  • The validated scenario list contains only the two scenarios run in this project.
  • The next-project object reserves mon01 for a future extension.
  • Save the three handoff files.
  • Inspect the tracked changes by running:
git status --short

You should see the Go module, cmd/agent/main.go, both configuration files, the contract, README, ignore file, and handoff manifest. You should not see files under runs/.

Create the continuation checkpoint

The commit captures the reviewed source contract. The annotated tag gives the sequel an immutable name for that exact commit.

  • Stage and commit the tracked project files by running:
git add .
git commit -m "Complete guardrailed Go agent"

You should see a new commit summary containing the Go source and handoff files. Runtime artifacts remain ignored.

  • Create the annotated completion tag by running:
git tag -a project-1-complete -m "Guardrailed Go agent complete"

Before the final check, do you expect the working tree to be clean and the completion tag to be listed?

  • Verify the checkpoint by running:
git status --short
git tag --list project-1-complete

The first command should print nothing. The second should print project-1-complete.

You now have a clean Go checkpoint for a guardrailed infra01 analyst. The future mon01 extension has a clear starting contract without being claimed as current work.

Checkpoint not clean?

  • Confirm the commit completed if git status --short still lists tracked files.
  • Confirm the tag name is exactly project-1-complete.
  • Help me finish the Go continuation checkpoint.

Secret mission

Extend the Infrastructure Contract

Add a third infra01 scenario through configuration alone. Prove that the unchanged Go agent blocks its restart request, then record the extension without moving the original completion tag.

Clean Up Your Resources

Clean Up Your Resources

This project creates no paid API or cloud resources, so your only continuing inputs are existing lab hardware, electricity, and internet access. Choose whether to keep Ollama running, pause its service, or remove the local model and Go repository.

Resources you used:

  • The running Ollama service on control01.
  • The local gemma3:1b model stored on control01.
  • The Git repository at ~/local-ai-ops-agent, including the Go source and project-1-complete tag.
  • The decision artifacts under runs/, which may contain observations from infra01.

Keep everything running

No action needed. Choose this if you plan to continue testing the Go agent or start the sequel soon.

  • Keep the Ollama service active on control01.
  • Keep the gemma3:1b model available for local requests.
  • Keep ~/local-ai-ops-agent with its Go source, Git history, and handoff manifest.
  • Review files under runs/ before sharing terminal archives because they may contain infra01 observations.

Pause - I'll come back to this later

Shut down the local model service to release active system resources while keeping the model, Go repository, Git history, and run artifacts on disk.

  • Pause the Ollama service by running:
sudo systemctl stop ollama

The service stops accepting local model requests. The model files and ~/local-ai-ops-agent remain on disk.

Delete - I don't want to use this again

Deletion removes the local model and the entire Go repository. Archive anything you want to retain before continuing.

  • Start Ollama if it is paused and remove the local model by running:
sudo systemctl start ollama
ollama rm gemma3:1b

This removes the gemma3:1b model while leaving the Ollama software installed.

  • Stop the Ollama service after removing the model by running:
sudo systemctl stop ollama
  • Delete the Go repository and every decision artifact by running:
rm -rf ~/local-ai-ops-agent
  • Confirm the repository is gone by running:
ls ~/local-ai-ops-agent

Your terminal should report that ~/local-ai-ops-agent cannot be found. That confirms the Go source, Git history, handoff files, and runtime artifacts are no longer on disk.

Nice Work!

Nice Work!

You did it! Your Go operations agent now turns read-only evidence from infra01 into structured analysis through Ollama and saves the policy-reviewed result without remediation.

You've learned how to:

  • Generate a typed local model response from gemma3:1b through Ollama’s local chat API using Go.
  • Collect allowlisted Linux evidence from infra01 over non-interactive SSH. Save every guarded result without executing remediation.
  • Create a continuation checkpoint using contracts/decision.schema.json, handoff/PROJECT_STATE.json, and the immutable project-1-complete Git tag.
  • Complete the optional Secret Mission by adding infra_restart_request through configuration alone. Prove that restart_service remains blocked without changing cmd/agent/main.go or moving the completion tag.

Ready to quiz yourself?