Timothy July 11, 2026 ~7 min read

Timothy Part 8: Letting the Model Act, Tools, Permissions, and Skills

Phase 0 gave the model a clean chat surface and a cost ledger. Phase 1 gave it durable, resumable sessions with compaction. The obvious next question was: what happens when the model needs to do something?

The naive answer, “just let the model emit shell commands or curl calls,” is how most early agents end up with surprise rm -rf / or credential leaks. I wanted the model to be useful without ever being in charge of safety or execution.

The tool loop lives in one place (D-003)

Why put the entire think-act loop in the brain instead of letting each provider driver manage its own tool calling?

Providers are thin wire adapters. They translate their native format into a single normalized event stream (chunk, tool_start, tool_end, usage, done…) and they never loop. If every driver grew its own agent loop, the logic would drift, the safety rules would duplicate, and the transcript the user sees would no longer match what the model actually experienced.

Instead, one component owns the loop:

// internal/brain/loop/agent.go
for step := 1; ; step++ {
    directive := tools.CeilingFor(step, maxSteps)
    ... stream with (or without) tool defs ...
    calls := collectToolCalls(upstream)
    if directive == tools.StepForceSynthesis {
        // hard stop: emit Done and return
    }
    results := executeAll(calls)   // constraints + permissions
    feed results back as tool messages
}

The gateway sees ordinary completion requests. The brain owns the state machine, the ceilings, the permission parking, and the decision to drop tool schemas on the final step.

Typed tools with real descriptions

Every tool is defined once:

// internal/brain/tools/tool.go
type Tool struct {
    Name        string
    Description string          // written for a junior developer
    InputSchema json.RawMessage // strict JSON Schema
    Execute     func(ctx, args) (string, error)
}

The description is the model’s only manual. It must say what the tool does, when to use it, argument shapes, edge cases, and one concrete example. Vague marketing copy is worse than no tool at all.

The built-ins that shipped with Phase 2:

  • current_time / convert_time: IANA time zones, no magic.
  • calculate: arithmetic expression evaluator (no eval of arbitrary code).
  • web_fetch: public GET only, readable text extraction, hard block on private/link-local/CGNAT/metadata addresses.
  • shell: runs in a configured workspace root with timeout and output cap.
  • retrieve_output: fetches a previously offloaded result by ref.
  • load_skill: returns the body of a named skill pack.

All of them declare additionalProperties: false in their schemas. The constraint layer rejects anything that doesn’t match before the tool ever sees it.

Constraints are code, not prose (D-009)

A model can be persuaded. A Go function cannot.

Every execution goes through Constrained:

  1. JSON Schema validation (rejects with a corrective message the model can act on).
  2. Per-tool clamps (e.g. shell timeout is capped at 120s no matter what the model asked for).
  3. Path allowlisting for shell and future file tools (absolute paths required; symlinks resolved at execution time).
  4. Loop ceilings: at step 15 the model receives a warning; at step 16 tool schemas are dropped and it must synthesize.

Violations become tool results, never panics. The model gets another turn to correct itself.

The same layer also enforces minimum retrieval for research-category tasks. A model that tries to answer a research question without calling a tool gets a single coercion message and must try again.

The permission chain (D-010)

Unattended operation means the user must be able to sleep. The chain is:

  1. Policy guard (hard deny, never overridable): dotfiles, .env*, ~/.ssh, ~/.aws, system directories, credential files. Any token containing .. that could climb out of the workspace is refused at the lexical level.
  2. Danger classifier for shell: scored patterns. rm, git push --force, sudo, docker, >file, curl | sh, package installs, etc. score high enough to force an interactive prompt even if a grant exists.
  3. Project allowlist (future panel).
  4. Session grants (“allow for this session”).
  5. Interactive prompt.

When the chain says “ask”, the turn parks. The SSE stream emits a permission_request carrying the tool, the full args, a danger level, and a rationale. The client presents the request; the user chooses once / session / deny. The decision is posted back; the loop resumes or receives a denial as tool feedback.

Opaque constructs are treated as destructive by design:

  • Command substitution `...` or $(...)
  • Process substitution <(...)
  • Variable indirection (x=rm; $x)
  • eval, exec, source, . ./script
  • Interpreter one-liners (sh -c, python -c, …)

If the classifier literally cannot see what will run, the safe default is to ask. Newlines count as command separators because sh -c bodies are frequently multi-line.

Big results never bloat the context (D-019)

A 20,000-line build log or search result is common. Sending it raw destroys the rest of the turn.

Above a threshold (default 8 KiB, lowered to 4 KiB for shell), the result is written to tool_outputs, the model receives a compact deterministic digest:

[shell output offloaded: 65552 bytes, 12775 lines.
Full content: retrieve_output {"ref": "dc9d6fa2-..."}]

The digest includes head lines, tail lines, and any error-looking lines. The retrieve_output tool can page the full content back in later (and is itself subject to the same offload rule so it never chases its tail).

Everything is retained for seven days and then GC’d.

Effort dialing (D-020)

A routine successful tool result does not need the model to think as hard as a fresh user question or an error.

After a clean step the loop emits effort=low on the next request. A failure, permission denial, or new user message keeps normal effort. Drivers that understand the hint (reasoning_effort, etc.) use it; others ignore it. The classification is a pure Go function.

Skills as lazy-loaded packs (D-008)

Putting the entire body of every capability into the system prompt does not scale.

Each skill is a directory with a SKILL.md:

---
name: coding-task
description: Plan-first, test-first, verify end-to-end. Use when you are asked to build or modify code.
---

# Rules
- ...

The system prompt receives only the one-line index. When the model decides a skill is relevant it calls load_skill{"name": "coding-task"} and receives the full body as a transient tool result. Token counts prove the bodies are absent from baseline turns and appear only when loaded.

The format is strict:

  • Must contain a “Use when …” trigger phrase in the description.
  • Body is principles only, no n-shot examples.
  • Optional Anti-rationalization, Red flags, and Evidence required sections (still principles, never worked examples).
  • Bodies longer than ~500 lines are expected to split into references/.

A CLI validator (make skills-validate) catches format problems and description collisions.

Surfacing tool activity and permissions

Tool calls and their results are part of the transcript. When a permission request is required the turn parks; the owner decides (once / session / deny) and the loop resumes with the outcome or denial.

Parallel calls are supported within the bounded loop.

What the hardening actually caught

During live use and adversarial testing several classes of bypass were closed:

  • A command like x=rm; $x -rf data or git${IFS}push would have looked safe to a naive pattern matcher.
  • echo "today is $(date)" or bash -c "$(curl ...)" hid the real payload.
  • Relative paths with .. could climb out of the workspace if only absolute paths were checked.

All of these now produce a destructive classification with an explicit “opaque command (…)” rationale. The model is forced to ask.

What ships with Phase 2

  • Five core tools + load_skill
  • Full constraint and permission machinery
  • Four seed skills (coding-task, research-brief, travel-planning, markets-digest)
  • Tool results and permission requests surface through the authenticated API (owner decides once/session/deny)
  • Audit rows and tool_execution events for every call
  • Offload + retrieve for large outputs
  • Deterministic effort reduction on routine continuations

The model can now fetch public pages, run calculations, inspect and modify files inside its workspace, and follow structured disciplines, all while the user retains the final say on anything destructive or ambiguous.

Next up is giving it long-term memory that belongs to the owner, not the model vendor. That changes the game again.


All of the above is on main as of the tool-execution merge. The live stack and the unit tables are the source of truth.

// end of article — process exited with code 0

// share:Twitter / XLinkedIn

// comments

loading...