Agentic AIWithout theToken Tax

Building a Deterministic, On-Device Action Engine

Fifteen steps to a small Swift engine that turns plain requests into checked, repeatable actions on your own Mac. Rules go first, a local model handles the rest, and no token leaves the machine.

The tax, and the way around it

Most agent frameworks run a loop. Every turn sends the system prompt, every tool schema, and the whole conversation back to a cloud model. You pay for all of it again on the next turn, and you pay to decide things you already know how to decide: what day Friday is, which file to write, whether a step already ran.

Railways settled this long ago. A signaller asks for a route. An interlocking, a machine with no opinions, refuses any route that conflicts with another. Judgment proposes, and the machine disposes.

The AI proposes, code validates, and deterministic software executes.

rules
Known requests become plans in about a millisecond, with no model at all
model
Gemma 3 4B or Apple's on-device model for the rest, held to a grammar
gate
A validator that refuses loops, past dates, and anything outside four actions
approval
A plan hash, so what you read is byte for byte what runs
effects
Steps that survive a crash and never run twice
proof
A replayable audit log and a scored test set

Four rules for the build

  1. The model proposes. It never acts. It fills in a form, and code does everything after that.
  2. Code owns the facts. Dates, paths, ids, counts, and order never come from a model.
  3. Take the cheapest route first. A regular expression beats a model whenever it can.
  4. Approve the exact plan. A hash ties what you read to what runs.

You need macOS 26 or later, Xcode 27, Homebrew, and 2.5 GB of disk for the model. There are no Swift dependencies and no API keys. Tick each step as you finish it. Your progress stays in this browser.

Prefer to read the finished thing first? Download the whole package (act.zip), with both test sets.

What deterministic means here. Rules, the validator, and the executor are plain functions of their inputs. A model is not, so its reply is treated as an input: recorded once, then replayed exactly. Greedy decoding repeated every result in these tests, but a new model or llama.cpp build can change a reply. Everything after the reply stays fixed.

requestruleslocal modelgateapproveexecutejournalrefused, with a reason0 tokens~360 local tokensknown shape
The whole engine as a track plan. Only the siding touches a model, and every route passes the gate.

Price the loop you are replacing

A loop's input grows with every turn, because each turn resends everything before it. Move the dials and watch the stack.

  1. turn 1 3,000
  2. turn 2 3,500
  3. turn 3 4,000
  4. turn 4 4,500
  5. turn 5 5,000
One task sends
20,000 input tokens
A 30-day month costs
$1,069
The engine you are about to build
0 cloud tokens, $0
The defaults are assumptions, not measurements. Each turn also returns 150 output tokens. Put in your own traffic and your provider's current prices. Prompt caching cuts the price of repeated input, not the number of tokens sent.

Close the set

Decide what the engine can do, and give every plan one exact form.

1. Start a package with no dependencies

The engine is one library, one command-line tool, and a test target. There is no agent framework, SDK, or API key.

mkdir act && cd act
mkdir -p Sources/ActionEngine Sources/act Tests/ActionEngineTests
printf '.build/\n.act/\n' > .gitignore
Package.swift
// swift-tools-version: 6.2
import PackageDescription

let package = Package(
    name: "act",
    platforms: [.macOS(.v26)],
    targets: [
        .target(name: "ActionEngine"),
        .executableTarget(name: "act", dependencies: ["ActionEngine"]),
        .testTarget(name: "ActionEngineTests", dependencies: ["ActionEngine"]),
    ]
)

Add two placeholder files so the package builds from the first step. Step 13 and step 14 replace them.

echo 'print("act")' > Sources/act/main.swift
echo 'import Testing' > Tests/ActionEngineTests/EngineTests.swift

2. Close the set of actions

An agent that can call anything has to be checked against everything. Start the other way: write down everything the engine may do. An enum has no case other, so the compiler helps you refuse the rest.

Sources/ActionEngine/Action.swift
import Foundation

/// Everything the engine can ever do. There is no `case other`.
public enum Action: Codable, Equatable, Sendable {
    case addTask(title: String, due: Date?)
    case scheduleEvent(title: String, start: Date, minutes: Int)
    case saveNote(title: String, body: String)
    case extractTasks(note: String)
}

public struct Step: Codable, Equatable, Sendable {
    public var id: String
    public var action: Action
    public var after: [String]

    public init(id: String, action: Action, after: [String] = []) {
        self.id = id
        self.action = action
        self.after = after
    }
}

public struct Plan: Codable, Equatable, Sendable {
    public var steps: [Step]

    public init(steps: [Step]) { self.steps = steps }
}

Each Step lists the steps it waits for in after, so a plan is a small graph. Four actions are enough to show every idea in this guide. Yours will differ, but the list stays closed.

3. Give every plan one exact form

Approval means nothing if the thing approved can shift. Canonical JSON gives each value exactly one byte sequence: sorted keys and a fixed date format. Hash those bytes and you have a name for the exact plan.

Sources/ActionEngine/Canonical.swift
import CryptoKit
import Foundation

/// One value, one byte sequence: sorted keys, fixed date format.
public func canonicalJSON<T: Encodable>(_ value: T) throws -> Data {
    let encoder = JSONEncoder()
    encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
    encoder.dateEncodingStrategy = .iso8601
    return try encoder.encode(value)
}

public func fingerprint<T: Encodable>(_ value: T) throws -> String {
    let digest = SHA256.hash(data: try canonicalJSON(value))
    return digest.prefix(6).map { String(format: "%02x", $0) }.joined()
}

public func decodeCanonical<T: Decodable>(_ type: T.Type, from data: Data) throws -> T {
    let decoder = JSONDecoder()
    decoder.dateDecodingStrategy = .iso8601
    return try decoder.decode(type, from: data)
}
swift build

Six bytes is plenty here. Forty-eight bits names plans in one person's folder. If plans cross a trust boundary, keep the full digest and sign it.

Spend nothing first

Two steps that handle known requests with no model at all.

4. Take the calendar away from the model

Code is perfect at calendars, and it is cheap. So the model never works out a date. It copies the user's words, and this file resolves them against a clock that is passed in, which makes every result repeatable.

Sources/ActionEngine/When.swift
import Foundation

/// Turns "friday at 3pm" into a date. Code owns the calendar, never the model.
public struct When: Sendable {
    public let now: Date
    public let calendar: Calendar

    public init(now: Date, timeZone: TimeZone = .gmt) {
        var calendar = Calendar(identifier: .gregorian)
        calendar.timeZone = timeZone
        self.now = now
        self.calendar = calendar
    }

    static let days = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"]

    /// The phrase must be a date, a time, or both, and nothing else.
    public func resolve(_ phrase: String) -> Date? {
        let pattern = /^(?:(?:on |next )?(?<day>today|tomorrow|sunday|monday|tuesday|wednesday|thursday|friday|saturday))?\s*(?:(?:at )?(?<hour>\d{1,2})(?::(?<minute>\d{2}))?\s*(?<ampm>am|pm)?)?$/
        let text = phrase.lowercased().trimmingCharacters(in: .whitespaces)
        guard !text.isEmpty, let match = text.wholeMatch(of: pattern) else { return nil }

        var hour = 9, minute = 0
        if let raw = match.hour {
            hour = Int(raw)!
            minute = match.minute.map { Int($0)! } ?? 0
            if let ampm = match.ampm {
                guard (1...12).contains(hour) else { return nil }
                hour = hour % 12 + (ampm == "pm" ? 12 : 0)
            } else if match.minute == nil {
                return nil // "at 3" could be either. Do not guess.
            }
        }
        guard (0...23).contains(hour), (0...59).contains(minute) else { return nil }

        let today = calendar.startOfDay(for: now)
        var offset = 0
        switch match.day.map(String.init) {
        case nil, "today": offset = 0
        case "tomorrow": offset = 1
        case let name?:
            let target = Self.days.firstIndex(of: name)! + 1
            let current = calendar.component(.weekday, from: today)
            offset = (target - current + 7) % 7
            if offset == 0 { offset = 7 }
        }
        let day = calendar.date(byAdding: .day, value: offset, to: today)!
        return calendar.date(bySettingHour: hour, minute: minute, second: 0, of: day)
    }
}

Notice what "at 3" does: it returns nil. Morning or afternoon is the user's call, so the engine refuses instead of guessing. Also, next friday means the coming Friday here. If your users mean something else, change it in this one place.

5. Answer known shapes with no model

A request with a known shape does not need a model. Match it, build the plan, and spend nothing. Anything the rules cannot read returns nil and moves on.

Sources/ActionEngine/Rules.swift
import Foundation

/// Tier 0. Known request shapes become plans with no model at all.
public struct Rules: Sendable {
    public let when: When

    public init(when: When) { self.when = when }

    public func plan(_ request: String) -> Plan? {
        let parts = request
            .split(separator: /\s+(?:and\s+)?then\s+/)
            .map { $0.trimmingCharacters(in: .whitespaces) }
        var steps: [Step] = []
        for (index, part) in parts.enumerated() {
            guard let action = action(part) else { return nil }
            let after = index == 0 ? [] : ["s\(index)"]
            steps.append(Step(id: "s\(index + 1)", action: action, after: after))
        }
        return steps.isEmpty ? nil : Plan(steps: steps)
    }

    func action(_ text: String) -> Action? {
        if let m = text.wholeMatch(of: /(?i)(?:remind me to|add a task to|add task:?|todo:?)\s+(?<rest>.+)/) {
            let (title, phrase) = splitWhen(String(m.rest))
            if let phrase {
                guard let due = when.resolve(phrase) else { return nil }
                return .addTask(title: title, due: due)
            }
            return .addTask(title: title, due: nil)
        }
        if let m = text.wholeMatch(of: /(?i)(?:schedule|book)\s+(?!me\b)(?<rest>.+?)(?:\s+for\s+(?<n>\d+)\s*(?<unit>minutes?|mins?|hours?))?/) {
            let (title, phrase) = splitWhen(String(m.rest))
            guard let phrase, let start = when.resolve(phrase) else { return nil }
            var minutes = 30
            if let n = m.n.flatMap({ Int($0) }) {
                minutes = m.unit?.lowercased().hasPrefix("hour") == true ? n * 60 : n
            }
            return .scheduleEvent(title: title, start: start, minutes: minutes)
        }
        if let m = text.wholeMatch(of: /(?i)(?:save a note|save note|note)(?:\s+(?:called|titled))?\s+(?<title>[^:]+):\s*(?<body>.+)/) {
            return .saveNote(title: String(m.title), body: String(m.body))
        }
        if let m = text.wholeMatch(of: /(?i)extract tasks from\s+(?<a>.+)|turn\s+(?<b>.+)\s+into tasks/) {
            return .extractTasks(note: String(m.a ?? m.b ?? ""))
        }
        return nil
    }

    /// Splits "send the invoice friday at 3pm" into a title and a date phrase.
    func splitWhen(_ text: String) -> (String, String?) {
        let tail = /(?i)\s+(?<phrase>(?:on |next )?(?:today|tomorrow|sunday|monday|tuesday|wednesday|thursday|friday|saturday)(?:\s+(?:at )?\d{1,2}(?::\d{2})?\s*(?:am|pm)?)?|at \d{1,2}(?::\d{2})?\s*(?:am|pm)?)$/
        guard let m = text.firstMatch(of: tail) else { return (text, nil) }
        return (String(text[..<m.range.lowerBound]), String(m.phrase))
    }
}

Try it. This box runs a port of that file, and all 108 requests in the parity test gave the same result and hash in Swift.

route   rules
tokens  0 local, 0 cloud
s1      {"addTask":{"due":"2026-11-06T15:00:00Z","title":"send the invoice"}}
hash    
This is the same logic, ported to run here. The clock is pinned to 5 November 2026 at 09:00 UTC, so every reader gets the same hash, and so does the Swift build.

An eager rule is worse than no rule. An early draft of this file read "turn off the lights" as a request to extract tasks from a note called "off the lights". That is why into tasks is required, and why book refuses to match book me. If a pattern is not sure, it should return nil.

Let the model speak, on a short lead

Some requests need language understanding. The model gets a form, not a tool belt.

6. Write the only thing a model may say

The model never sees Action. It fills in a flat form of five fields, and dates stay as words. This file is the bridge from words to types.

Sources/ActionEngine/Proposal.swift
import Foundation

/// What a model may say. Dates stay as words here; code resolves them later.
public struct Proposal: Codable, Equatable, Sendable {
    public struct Item: Codable, Equatable, Sendable {
        public var action: String
        public var title: String
        public var when: String
        public var minutes: Int
        public var body: String
    }
    public var steps: [Item]

    public static let schema = """
    {"type":"object","required":["steps"],"additionalProperties":false,"properties":{
      "steps":{"type":"array","minItems":1,"maxItems":4,"items":{
        "type":"object","additionalProperties":false,
        "required":["action","title","when","minutes","body"],
        "properties":{
          "action":{"enum":["add_task","schedule_event","save_note","extract_tasks","none"]},
          "title":{"type":"string","maxLength":120},
          "when":{"type":"string","maxLength":40},
          "minutes":{"type":"integer","minimum":0,"maximum":480},
          "body":{"type":"string","maxLength":800}}}}}}
    """

    public static let instructions = """
    You turn one request into JSON steps. Pick "action" from this list only:
    add_task: something to do or remember. title is the task. when is the due date in the user's words, or "".
    schedule_event: a meeting or appointment. title is the event. when is the start in the user's words. minutes is the length, or 0.
    save_note: keep some text. title is the note name, the words before the colon. body is all the words after the colon. when stays "".
    extract_tasks: pull tasks out of a saved note. title is the note name.
    none: anything else, including anything you cannot do with these four. Leave the other fields empty.
    Copy dates exactly as the user wrote them. Never work out a date.

    Examples:
    "I must return the library books by monday" add_task title "return the library books" when "monday"
    "get coffee with Ana in my diary for thursday at 9am" schedule_event title "coffee with Ana" when "thursday at 9am"
    "store this as Recipes: two eggs, one lemon" save_note title "Recipes" body "two eggs, one lemon"
    "find the to-dos in my Budget note" extract_tasks title "Budget"
    "order me a pizza" none
    """
}

public enum ProposalError: Error, Equatable, CustomStringConvertible {
    case declined
    case unreadableDate(String)
    case missingDate(String)

    public var description: String {
        switch self {
        case .declined: "The request is outside the four things this engine can do."
        case .unreadableDate(let text): "\"\(text)\" is not a date this engine can read."
        case .missingDate(let title): "\"\(title)\" needs a start time."
        }
    }
}

extension Proposal {
    /// The bridge from words to a typed plan. Everything past here is plain code.
    public func plan(when clock: When) throws(ProposalError) -> Plan {
        var result: [Step] = []
        for (index, item) in steps.enumerated() {
            let after = index == 0 ? [] : ["s\(index)"]
            let step = Step(id: "s\(index + 1)", action: try action(item, clock), after: after)
            result.append(step)
        }
        return Plan(steps: result)
    }

    private func action(_ item: Item, _ clock: When) throws(ProposalError) -> Action {
        switch item.action {
        case "add_task":
            return .addTask(title: item.title, due: try date(item.when, clock))
        case "schedule_event":
            guard let start = try date(item.when, clock) else { throw .missingDate(item.title) }
            return .scheduleEvent(title: item.title, start: start, minutes: item.minutes == 0 ? 30 : item.minutes)
        case "save_note":
            return .saveNote(title: item.title, body: item.body)
        case "extract_tasks":
            return .extractTasks(note: item.title)
        default:
            throw .declined
        }
    }

    private func date(_ words: String, _ clock: When) throws(ProposalError) -> Date? {
        if words.isEmpty { return nil }
        guard let date = clock.resolve(words) else { throw .unreadableDate(words) }
        return date
    }
}

The none option matters more than it looks. A model with no way to say no will say something else. And note the instruction never work out a date: the form has a place for the user's words, and no place for a calculated one.

7. Run a local model under a grammar

llama.cpp runs Gemma on the GPU of your Mac. Its --json-schema flag compiles the schema into a grammar, and the sampler can only pick tokens the grammar allows. Install it and fetch the model. The checksum should match.

brew install llama.cpp
mkdir -p ~/models
curl -L -o ~/models/gemma-3-4b-it-Q4_K_M.gguf \
  https://huggingface.co/ggml-org/gemma-3-4b-it-GGUF/resolve/d0976223747697cb51e056d85c532013931fe52e/gemma-3-4b-it-Q4_K_M.gguf
shasum -a 256 ~/models/gemma-3-4b-it-Q4_K_M.gguf
882e8d2db44dc554fb0ea5077cb7e4bc49e7342a1f0da57901c0802ea21a0863
Sources/ActionEngine/LlamaProposer.swift
import Foundation

public struct ModelReply: Sendable {
    public var raw: String
    public var tokens: Int
}

public protocol Proposer: Sendable {
    var name: String { get }
    func propose(_ request: String) async throws -> ModelReply
}

/// Runs any GGUF model on this machine. The grammar comes from the JSON schema,
/// so tokens that would break it are never sampled.
public struct LlamaProposer: Proposer {
    public let modelPath: String
    public var name: String { URL(filePath: modelPath).deletingPathExtension().lastPathComponent }

    public init(modelPath: String) { self.modelPath = modelPath }

    public func propose(_ request: String) async throws -> ModelReply {
        let log = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
        FileManager.default.createFile(atPath: log.path, contents: nil)
        defer { try? FileManager.default.removeItem(at: log) }

        let process = Process()
        let output = Pipe()
        process.executableURL = URL(filePath: "/usr/bin/env")
        process.arguments = [
            "llama-completion", "-m", modelPath,
            "-sys", Proposal.instructions, "-p", request,
            "--json-schema", Proposal.schema,
            "--temp", "0", "--seed", "42", "-n", "256", "-c", "2048",
            "-st", "--no-display-prompt", "--simple-io", "--perf",
        ]
        process.standardOutput = output
        process.standardError = try FileHandle(forWritingTo: log)
        process.standardInput = FileHandle.nullDevice
        try process.run()
        let data = output.fileHandleForReading.readDataToEndOfFile()
        process.waitUntilExit()

        let text = String(decoding: data, as: UTF8.self)
        guard let start = text.firstIndex(of: "{"), let end = text.lastIndex(of: "}") else {
            throw CocoaError(.coderReadCorrupt)
        }
        let perf = (try? String(contentsOf: log, encoding: .utf8)) ?? ""
        return ModelReply(raw: String(text[start...end]), tokens: Self.tokens(in: perf))
    }

    /// llama.cpp ends its timings with "total time = ... / 412 tokens".
    static func tokens(in log: String) -> Int {
        log.matches(of: /total time.*\/\s+(\d+) tokens/).last.flatMap { Int($0.1) } ?? 0
    }
}

Temperature 0 picks the most likely token every time, and the seed fixes the rest. Starting a process per request reloads the model on each call, which keeps this file short. A real app would link the library and keep the model in memory.

Here is what the grammar buys. All 30 held-out requests went to each model, with and without it:

Replies that fit the schemaWith grammarFree text
Gemma 3 4B30 of 3014 of 30
Gemma 3 1B30 of 300 of 30

A grammar guarantees shape, not truth. Gemma 1B always produced valid JSON, and it still filed nearly everything under the wrong action. Validity is what the grammar gives you. Correctness needs the gate, the approval step, and a test set.

8. Swap in Apple's model

Macs and iPhones with Apple Intelligence already carry a model. Foundation Models has the same idea built in: @Generable types and @Guide limits constrain decoding, so this is the same contract with nothing to download.

Sources/ActionEngine/AppleProposer.swift
#if canImport(FoundationModels)
import Foundation
import FoundationModels

/// The same contract on Apple's built-in model. Nothing to download.
@available(macOS 26, iOS 26, *)
public struct AppleProposer: Proposer {
    @Generable
    struct Draft {
        @Generable
        struct Item {
            @Guide(.anyOf(["add_task", "schedule_event", "save_note", "extract_tasks", "none"]))
            var action: String
            var title: String
            @Guide(description: "The date in the user's words, or empty")
            var when: String
            @Guide(.range(0...480))
            var minutes: Int
            var body: String
        }
        @Guide(.count(1...4))
        var steps: [Item]
    }

    public let name = "apple-on-device"

    public init() {}

    public static var isAvailable: Bool {
        if case .available = SystemLanguageModel.default.availability { return true }
        return false
    }

    public func propose(_ request: String) async throws -> ModelReply {
        let session = LanguageModelSession(instructions: Proposal.instructions)
        let options = GenerationOptions(samplingMode: .greedy)
        let draft = try await session.respond(to: request, generating: Draft.self, options: options).content
        let proposal = Proposal(steps: draft.steps.map {
            .init(action: $0.action, title: $0.title, when: $0.when, minutes: $0.minutes, body: $0.body)
        })
        let raw = String(decoding: try canonicalJSON(proposal), as: UTF8.self)
        return ModelReply(raw: raw, tokens: 0)
    }
}
#endif

Because both sit behind Proposer, nothing else changes. The command line in step 13 uses Gemma when ACT_MODEL is set, and Apple's model otherwise.

Compiled, not measured. This file builds with Xcode 27, but Apple Intelligence was off on the test Mac, so every number in this guide comes from Gemma. It also reports no token count, so local reads 0 on this path.

Gate every route

Rules and models both produce plans. Neither one is trusted.

9. Build the interlocking

This is the machine with no opinions. It checks limits, checks that every dependency exists, and puts the steps in order. Ties keep plan order, so the result never varies. Failures carry a reason a person can act on.

Sources/ActionEngine/Validator.swift
import Foundation

public enum PlanError: Error, Equatable, CustomStringConvertible {
    case empty
    case tooManySteps(Int)
    case duplicateStep(String)
    case unknownDependency(step: String, needs: String)
    case cycle
    case badField(step: String, reason: String)

    public var description: String {
        switch self {
        case .empty: "The plan has no steps."
        case .tooManySteps(let n): "The plan has \(n) steps. The limit is \(Validator.maxSteps)."
        case .duplicateStep(let id): "Two steps share the id \(id)."
        case .unknownDependency(let step, let needs): "Step \(step) waits for \(needs), which does not exist."
        case .cycle: "The steps wait for each other in a loop."
        case .badField(let step, let reason): "Step \(step): \(reason)"
        }
    }
}

public struct Validator: Sendable {
    public static let maxSteps = 8
    public let now: Date

    public init(now: Date) { self.now = now }

    /// Returns the steps in the order they must run, or the reason they cannot.
    public func check(_ plan: Plan) throws(PlanError) -> [Step] {
        guard !plan.steps.isEmpty else { throw .empty }
        guard plan.steps.count <= Self.maxSteps else { throw .tooManySteps(plan.steps.count) }

        var ids = Set<String>()
        for step in plan.steps {
            guard ids.insert(step.id).inserted else { throw .duplicateStep(step.id) }
        }
        for step in plan.steps {
            try checkFields(step)
            for needed in step.after where !ids.contains(needed) {
                throw .unknownDependency(step: step.id, needs: needed)
            }
        }
        return try ordered(plan.steps)
    }

    func checkFields(_ step: Step) throws(PlanError) {
        func bad(_ reason: String) -> PlanError { .badField(step: step.id, reason: reason) }
        let horizon = now.addingTimeInterval(2 * 365 * 86_400)

        switch step.action {
        case .addTask(let title, let due):
            try checkTitle(title, step)
            if let due, due < now || due > horizon { throw bad("the due date is not in the next two years.") }
        case .scheduleEvent(let title, let start, let minutes):
            try checkTitle(title, step)
            if start < now || start > horizon { throw bad("the start is not in the next two years.") }
            if !(5...480).contains(minutes) { throw bad("an event lasts 5 to 480 minutes.") }
        case .saveNote(let title, let body):
            try checkTitle(title, step)
            if body.isEmpty || body.count > 4_000 { throw bad("a note body has 1 to 4,000 characters.") }
        case .extractTasks(let note):
            try checkTitle(note, step)
        }
    }

    func checkTitle(_ title: String, _ step: Step) throws(PlanError) {
        if title.trimmingCharacters(in: .whitespaces).isEmpty || title.count > 120 {
            throw .badField(step: step.id, reason: "a title has 1 to 120 characters.")
        }
        if title.contains(where: \.isNewline) {
            throw .badField(step: step.id, reason: "a title is a single line.")
        }
    }

    /// Kahn's algorithm. Ties keep plan order, so the result never varies.
    func ordered(_ steps: [Step]) throws(PlanError) -> [Step] {
        var done = Set<String>()
        var result: [Step] = []
        while result.count < steps.count {
            guard let next = steps.first(where: {
                !done.contains($0.id) && $0.after.allSatisfy(done.contains)
            }) else { throw .cycle }
            done.insert(next.id)
            result.append(next)
        }
        return result
    }
}

Now try to get a bad plan past it. These are real messages from the same logic, running in this page.

{"steps":[{"action":{"deleteFiles":{"path":"~"}},"after":[],"id":"s1"}]}

Refused. The request is outside the four things this engine can do.

10. Route the cheapest way first

The router is short because the hard decisions already live elsewhere. Rules go first, the model hears only what they could not read, and both routes pass through the same seal.

Sources/ActionEngine/Engine.swift
import Foundation

public struct Routed: Codable, Equatable, Sendable {
    public var request: String
    public var now: Date
    public var route: String
    public var plan: Plan
    public var hash: String
    public var raw: String?
    public var localTokens: Int
    public var cloudTokens: Int
    public var milliseconds: Double
}

public enum EngineError: Error, CustomStringConvertible {
    case noRoute
    case rejected(String)

    public var description: String {
        switch self {
        case .noRoute: "No rule matched, and no local model is set."
        case .rejected(let reason): reason
        }
    }
}

public struct Engine: Sendable {
    public let now: Date
    public let timeZone: TimeZone
    public let proposer: (any Proposer)?

    public init(now: Date, timeZone: TimeZone = .gmt, proposer: (any Proposer)? = nil) {
        self.now = now
        self.timeZone = timeZone
        self.proposer = proposer
    }

    /// Rules first. The model only hears what the rules could not read.
    public func plan(_ request: String) async throws -> Routed {
        let clock = When(now: now, timeZone: timeZone)
        let started = ContinuousClock.now

        if let plan = Rules(when: clock).plan(request) {
            return try seal(request, "rules", plan, nil, 0, started)
        }
        guard let proposer else { throw EngineError.noRoute }

        let reply = try await proposer.propose(request)
        let plan = try Self.decode(reply.raw, clock)
        return try seal(request, proposer.name, plan, reply.raw, reply.tokens, started)
    }

    /// Rebuilds a plan from a record without running any model.
    public static func replay(_ record: Routed, timeZone: TimeZone = .gmt) throws -> Plan {
        let clock = When(now: record.now, timeZone: timeZone)
        if let raw = record.raw { return try decode(raw, clock) }
        guard let plan = Rules(when: clock).plan(record.request) else { throw EngineError.noRoute }
        return plan
    }

    static func decode(_ raw: String, _ clock: When) throws -> Plan {
        let proposal = try JSONDecoder().decode(Proposal.self, from: Data(raw.utf8))
        do {
            return try proposal.plan(when: clock)
        } catch {
            throw EngineError.rejected(error.description)
        }
    }

    private func seal(
        _ request: String, _ route: String, _ plan: Plan, _ raw: String?, _ tokens: Int,
        _ started: ContinuousClock.Instant
    ) throws -> Routed {
        do {
            _ = try Validator(now: now).check(plan)
        } catch {
            throw EngineError.rejected(error.description)
        }
        let elapsed = started.duration(to: .now).components
        let ms = Double(elapsed.seconds) * 1_000 + Double(elapsed.attoseconds) / 1e15
        return Routed(
            request: request, now: now, route: route, plan: plan, hash: try fingerprint(plan),
            raw: raw, localTokens: tokens, cloudTokens: 0, milliseconds: ms
        )
    }
}

cloudTokens is a literal 0. That is the point of the design: there is no network code in this package, so no code path could make it anything else. replay rebuilds a plan from a record without touching a model, because the record holds the model's reply.

Act exactly once

Side effects are where agents do real damage. Three files make them safe to retry.

11. Write handlers that are safe to run twice

All files live under one root, and a title can never become a path, because slug keeps only letters, digits, and hyphens. ../../.ssh/authorized_keys becomes the harmless ssh-authorized-keys.

Sources/ActionEngine/Workspace.swift
import Foundation

/// Every file the engine touches lives under one root.
public struct Workspace: Sendable {
    public let root: URL

    public init(root: URL) throws {
        self.root = root
        for folder in ["notes", "events", "plans"] {
            try FileManager.default.createDirectory(
                at: root.appending(path: folder), withIntermediateDirectories: true)
        }
    }

    public var tasks: URL { root.appending(path: "tasks.md") }
    public var journal: URL { root.appending(path: "journal.jsonl") }
    public var audit: URL { root.appending(path: "audit.jsonl") }
    public func note(_ title: String) -> URL { root.appending(path: "notes/\(Self.slug(title)).md") }
    public func plan(_ hash: String) -> URL { root.appending(path: "plans/\(hash).json") }

    /// Letters, digits, and hyphens only, so a title can never become a path.
    public static func slug(_ text: String) -> String {
        let words = text.lowercased().split { !($0.isASCII && ($0.isLetter || $0.isNumber)) }
        return words.isEmpty ? "untitled" : words.joined(separator: "-")
    }

    public func append(_ line: Data, to file: URL) throws {
        if !FileManager.default.fileExists(atPath: file.path) {
            FileManager.default.createFile(atPath: file.path, contents: nil)
        }
        let handle = try FileHandle(forWritingTo: file)
        defer { try? handle.close() }
        try handle.seekToEnd()
        try handle.write(contentsOf: line + Data("\n".utf8))
    }

    public func lines(of file: URL) -> [String] {
        guard let text = try? String(contentsOf: file, encoding: .utf8) else { return [] }
        return text.split(separator: "\n").map(String.init)
    }
}

Each handler takes a key and checks the world before it writes. A task carries its key in a hidden marker. An event's file name comes from its key, and its timestamp comes from the plan, not the clock. A note refuses to overwrite different text.

Sources/ActionEngine/Handlers.swift
import Foundation

public enum HandlerError: Error, CustomStringConvertible {
    case noteExists(String)
    case noteMissing(String)

    public var description: String {
        switch self {
        case .noteExists(let name): "A different note called \(name) already exists."
        case .noteMissing(let name): "There is no note called \(name)."
        }
    }
}

/// Each handler is safe to run twice with the same key.
struct Handlers {
    let workspace: Workspace

    func perform(_ action: Action, key: String) throws -> String {
        switch action {
        case .addTask(let title, let due):
            return try addTask(title, due: due, key: key)
        case .scheduleEvent(let title, let start, let minutes):
            return try writeEvent(title, start: start, minutes: minutes, key: key)
        case .saveNote(let title, let body):
            return try saveNote(title, body: body)
        case .extractTasks(let note):
            return try extractTasks(from: note, key: key)
        }
    }

    func addTask(_ title: String, due: Date?, key: String) throws -> String {
        let marker = "<!-- act:\(key) -->"
        if workspace.lines(of: workspace.tasks).contains(where: { $0.hasSuffix(marker) }) {
            return "tasks.md (already there)"
        }
        let date = due.map { " (due \($0.formatted(.iso8601)))" } ?? ""
        try workspace.append(Data("- [ ] \(title)\(date) \(marker)".utf8), to: workspace.tasks)
        return "tasks.md"
    }

    func writeEvent(_ title: String, start: Date, minutes: Int, key: String) throws -> String {
        let stamp = Date.ISO8601FormatStyle(dateSeparator: .omitted, timeSeparator: .omitted, timeZone: .gmt)
        let end = start.addingTimeInterval(Double(minutes) * 60)
        let summary = title
            .replacing("\\", with: "\\\\").replacing(";", with: "\\;").replacing(",", with: "\\,")
        let lines = [
            "BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//act//EN", "BEGIN:VEVENT",
            "UID:\(key)@act", "DTSTAMP:\(stamp.format(start))",
            "DTSTART:\(stamp.format(start))", "DTEND:\(stamp.format(end))",
            "SUMMARY:\(summary)", "END:VEVENT", "END:VCALENDAR", "",
        ]
        let name = "events/\(Workspace.slug(title))-\(key).ics"
        try Data(lines.joined(separator: "\r\n").utf8).write(to: workspace.root.appending(path: name), options: .atomic)
        return name
    }

    func saveNote(_ title: String, body: String) throws -> String {
        let file = workspace.note(title)
        let content = "# \(title)\n\n\(body)\n"
        if let existing = try? String(contentsOf: file, encoding: .utf8) {
            guard existing == content else { throw HandlerError.noteExists(title) }
            return "notes/\(file.lastPathComponent) (already there)"
        }
        try Data(content.utf8).write(to: file, options: .atomic)
        return "notes/\(file.lastPathComponent)"
    }

    func extractTasks(from note: String, key: String) throws -> String {
        guard let text = try? String(contentsOf: workspace.note(note), encoding: .utf8) else {
            throw HandlerError.noteMissing(note)
        }
        let found = text.split(whereSeparator: \.isNewline).compactMap { line in
            line.wholeMatch(of: /\s*(?:TODO:|- \[ \])\s*(.+)/).map { String($0.1) }
        }
        for (index, title) in found.enumerated() {
            _ = try addTask(title, due: nil, key: "\(key)-\(index)")
        }
        return "tasks.md (\(found.count) from \(note))"
    }
}

12. Keep a journal, so a crash is boring

The executor writes started, performs the effect, then writes done. The dangerous moment is between the last two: the effect happened, but the journal does not know. On the next run the step runs again, the handler finds its own marker, and nothing is duplicated.

Sources/ActionEngine/Executor.swift
import Foundation

public struct Entry: Codable, Equatable, Sendable {
    public var key: String
    public var step: String
    public var status: String
    public var detail: String
}

public struct Executor: Sendable {
    public let workspace: Workspace
    public let now: Date
    /// Tests use this to crash after a side effect, before it is recorded.
    public var crashAfter: String?

    public init(workspace: Workspace, now: Date, crashAfter: String? = nil) {
        self.workspace = workspace
        self.now = now
        self.crashAfter = crashAfter
    }

    public struct Crash: Error {}

    /// Runs a plan in dependency order. Finished steps are never run again.
    @discardableResult
    public func run(_ plan: Plan) throws -> [Entry] {
        let order = try Validator(now: now).check(plan)
        let handlers = Handlers(workspace: workspace)
        let finished = Set(journal().filter { $0.status == "done" }.map(\.key))
        var results: [Entry] = []

        for step in order {
            let key = try fingerprint(step)
            if finished.contains(key) {
                results.append(Entry(key: key, step: step.id, status: "skipped", detail: "already done"))
                continue
            }
            try record(Entry(key: key, step: step.id, status: "started", detail: ""))
            do {
                let detail = try handlers.perform(step.action, key: key)
                if crashAfter == step.id { throw Crash() }
                let entry = Entry(key: key, step: step.id, status: "done", detail: detail)
                try record(entry)
                results.append(entry)
            } catch is Crash {
                throw Crash()
            } catch {
                let reason = String(describing: error)
                try record(Entry(key: key, step: step.id, status: "failed", detail: reason))
                throw EngineError.rejected(reason)
            }
        }
        return results
    }

    func record(_ entry: Entry) throws {
        try workspace.append(try canonicalJSON(entry), to: workspace.journal)
    }

    public func journal() -> [Entry] {
        workspace.lines(of: workspace.journal).compactMap {
            try? decodeCanonical(Entry.self, from: Data($0.utf8))
        }
    }
}

The key is a hash of the step itself. Two identical requests make one task. That is usually what you want from a retry, and if you want a real duplicate, it has to differ in some way.

13. Wire up the command line, then run it

Replace the placeholder main.swift. plan and run are separate commands on purpose: reading the plan is the approval step, and the hash you pass to run is your signature on those exact bytes.

Sources/act/main.swift
import ActionEngine
import Foundation

let usage = """
usage: act plan "<request>"   Build a plan and print its hash
       act run <hash>         Run a plan you have read
       act replay <hash>      Rebuild a plan from the audit log, without a model
       act eval <cases.jsonl> Score the engine against a test set
"""

var arguments = Array(CommandLine.arguments.dropFirst())
let environment = ProcessInfo.processInfo.environment

@MainActor func option(_ name: String) -> String? {
    guard let index = arguments.firstIndex(of: name), index + 1 < arguments.count else { return nil }
    defer { arguments.removeSubrange(index...(index + 1)) }
    return arguments[index + 1]
}

func fail(_ message: Any) -> Never {
    print("stopped: \(message)")
    exit(1)
}

let now = option("--now").flatMap { try? Date($0, strategy: .iso8601) } ?? Date()
let timeZone = environment["ACT_TZ"].flatMap(TimeZone.init(identifier:)) ?? .current
let workspace = try Workspace(root: URL(filePath: environment["ACT_HOME"] ?? ".act"))

func proposer() -> (any Proposer)? {
    if let path = environment["ACT_MODEL"] { return LlamaProposer(modelPath: path) }
    #if canImport(FoundationModels)
    if AppleProposer.isAvailable { return AppleProposer() }
    #endif
    return nil
}

func describe(_ action: Action) -> String {
    let data = (try? canonicalJSON(action)) ?? Data()
    return String(decoding: data, as: UTF8.self)
}

@MainActor func load(_ hash: String) throws -> Routed? {
    try workspace.lines(of: workspace.audit).reversed()
        .lazy.compactMap { try? decodeCanonical(Routed.self, from: Data($0.utf8)) }
        .first { $0.hash == hash }
}

guard let command = arguments.first, arguments.count == 2 else {
    print(usage)
    exit(2)
}
let input = arguments[1]

do {
    switch command {
    case "plan":
        let engine = Engine(now: now, timeZone: timeZone, proposer: proposer())
        let routed = try await engine.plan(input)
        try canonicalJSON(routed.plan).write(to: workspace.plan(routed.hash))
        try workspace.append(try canonicalJSON(routed), to: workspace.audit)
        print("route   \(routed.route)")
        print("tokens  \(routed.localTokens) local, \(routed.cloudTokens) cloud")
        print("time    \(String(format: "%.2f", routed.milliseconds)) ms")
        for step in routed.plan.steps { print("\(step.id)      \(describe(step.action))") }
        print("hash    \(routed.hash)")

    case "run":
        let data = try Data(contentsOf: workspace.plan(input))
        let plan = try decodeCanonical(Plan.self, from: data)
        guard try fingerprint(plan) == input else { fail("this plan changed after you read it.") }
        for entry in try Executor(workspace: workspace, now: now).run(plan) {
            print("\(entry.step)  \(entry.status)  \(entry.detail)")
        }

    case "replay":
        guard let record = try load(input) else { fail("no record of \(input).") }
        let plan = try Engine.replay(record, timeZone: timeZone)
        print(try fingerprint(plan) == input ? "identical  \(input)" : "different  \(input)")

    case "eval":
        let report = try await Evaluation.run(
            file: URL(filePath: input), engine: Engine(now: now, timeZone: timeZone, proposer: proposer()))
        print(report)

    default:
        print(usage)
        exit(2)
    }
} catch {
    fail(error)
}

Add the scoring code now too. Step 15 puts it to work.

Sources/ActionEngine/Evaluation.swift
import Foundation

public struct Case: Codable, Sendable {
    public var request: String
    public var expect: [String]
    public var date: Date?
}

public enum Evaluation {
    public static func kind(_ action: Action) -> String {
        switch action {
        case .addTask: "addTask"
        case .scheduleEvent: "scheduleEvent"
        case .saveNote: "saveNote"
        case .extractTasks: "extractTasks"
        }
    }

    static func date(_ action: Action) -> Date? {
        switch action {
        case .addTask(_, let due): due
        case .scheduleEvent(_, let start, _): start
        default: nil
        }
    }

    public static func run(file: URL, engine: Engine) async throws -> String {
        let text = try String(contentsOf: file, encoding: .utf8)
        let cases = try text.split(separator: "\n").map {
            try decodeCanonical(Case.self, from: Data($0.utf8))
        }
        var routes: [String: (total: Int, right: Int, ms: Double, tokens: Int)] = [:]
        var wrong: [String] = []

        for item in cases {
            var route = "refused"
            var got = ["refused"]
            var right = false
            var ms = 0.0, tokens = 0
            do {
                let routed = try await engine.plan(item.request)
                route = routed.route
                ms = routed.milliseconds
                tokens = routed.localTokens
                got = routed.plan.steps.map { kind($0.action) }
                right = got == item.expect
                    && (item.date == nil || routed.plan.steps.first.flatMap { date($0.action) } == item.date)
            } catch {
                right = item.expect == ["refused"]
            }
            var row = routes[route] ?? (0, 0, 0, 0)
            row.total += 1
            row.right += right ? 1 : 0
            row.ms += ms
            row.tokens += tokens
            routes[route] = row
            if !right { wrong.append("  \(item.request)\n    wanted \(item.expect), got \(got)") }
        }

        var lines = ["route                     cases  right  avg ms   local tokens"]
        for (route, row) in routes.sorted(by: { $0.key < $1.key }) {
            let columns = [
                route.padding(toLength: 24, withPad: " ", startingAt: 0),
                String(format: "%5d  %5d  %7.2f  %6d", row.total, row.right, row.ms / Double(row.total), row.tokens),
            ]
            lines.append(columns.joined(separator: " "))
        }
        let right = routes.values.map(\.right).reduce(0, +)
        lines.append("\(right) of \(cases.count) right, 0 cloud tokens")
        if !wrong.isEmpty { lines.append("wrong:\n" + wrong.joined(separator: "\n")) }
        return lines.joined(separator: "\n")
    }
}

Build it. --now pins the clock so your output matches this page. Leave it off for real use, along with ACT_TZ, and your own clock and time zone apply.

swift build -c release
alias act="$(swift build -c release --show-bin-path)/act"
export ACT_TZ=UTC
act plan "remind me to send the invoice friday at 3pm" --now 2026-11-05T09:00:00Z
route   rules
tokens  0 local, 0 cloud
time    1.57 ms
s1      {"addTask":{"due":"2026-11-06T15:00:00Z","title":"send the invoice"}}
hash    c9cc2e7486b2

Run it twice. The second run does nothing.

act run c9cc2e7486b2 --now 2026-11-05T09:00:00Z
act run c9cc2e7486b2 --now 2026-11-05T09:00:00Z
cat .act/tasks.md
s1  done  tasks.md
s1  skipped  already done
- [ ] send the invoice (due 2026-11-06T15:00:00Z) <!-- act:bd3bd2911adb -->

Now a request no rule can read. Gemma answers in about a second and a half, and loading the model is nearly all of that.

export ACT_MODEL=~/models/gemma-3-4b-it-Q4_K_M.gguf
act plan "nudge me about the invoice on monday" --now 2026-11-05T09:00:00Z
route   gemma-3-4b-it-Q4_K_M
tokens  358 local, 0 cloud
time    1493.50 ms
s1      {"addTask":{"due":"2026-11-09T09:00:00Z","title":"about the invoice"}}
hash    e6dacc33cd54

The title came out as "about the invoice", which is clumsy but honest, and you can see it before anything runs. Rebuild that plan without a model, then tamper with the saved file:

unset ACT_MODEL
act replay e6dacc33cd54
sed -i '' 's/invoice/payroll/' .act/plans/e6dacc33cd54.json
act run e6dacc33cd54 --now 2026-11-05T09:00:00Z
identical  e6dacc33cd54
stopped: this plan changed after you read it.

Prove it

A demo shows the good cases. Tests and a held-out set show the rest.

14. Test everything without a model

Almost all of this engine is plain code, so almost all of it can be tested without one. Replace the placeholder test file. A canned Proposer stands in where a reply is needed, and a crash is injected at the worst possible moment.

Tests/ActionEngineTests/EngineTests.swift
import Foundation
import Testing

@testable import ActionEngine

let now = try! Date("2026-11-05T09:00:00Z", strategy: .iso8601)
let friday3pm = try! Date("2026-11-06T15:00:00Z", strategy: .iso8601)

func scratch() throws -> Workspace {
    try Workspace(root: FileManager.default.temporaryDirectory.appending(path: UUID().uuidString))
}

@Suite struct Dates {
    let when = When(now: now)

    @Test func resolvesWords() {
        #expect(when.resolve("friday at 3pm") == friday3pm)
        #expect(when.resolve("tomorrow 15:00") == friday3pm)
    }

    @Test func refusesToGuess() {
        #expect(when.resolve("at 3") == nil)
        #expect(when.resolve("when I get in") == nil)
        #expect(when.resolve("13pm") == nil)
    }
}

@Suite struct Tier0 {
    let rules = Rules(when: When(now: now))

    @Test func readsKnownShapes() {
        let plan = rules.plan("remind me to send the invoice friday at 3pm")
        #expect(plan?.steps == [Step(id: "s1", action: .addTask(title: "send the invoice", due: friday3pm))])
    }

    @Test func chainsSteps() {
        let plan = rules.plan("save a note called Standup: TODO: ship it then extract tasks from Standup")
        #expect(plan?.steps.count == 2)
        #expect(plan?.steps[1].after == ["s1"])
    }

    @Test func leavesTheRestAlone() {
        #expect(rules.plan("nudge me about the invoice when I get in") == nil)
        #expect(rules.plan("book me a flight to Lisbon on friday") == nil)
        #expect(rules.plan("turn off the lights") == nil)
    }
}

@Suite struct Gate {
    let validator = Validator(now: now)

    @Test func catchesLoops() {
        let plan = Plan(steps: [
            Step(id: "a", action: .saveNote(title: "A", body: "x"), after: ["b"]),
            Step(id: "b", action: .saveNote(title: "B", body: "x"), after: ["a"]),
        ])
        #expect(throws: PlanError.cycle) { try validator.check(plan) }
    }

    @Test func catchesThePast() {
        let plan = Plan(steps: [Step(id: "a", action: .addTask(title: "Late", due: .distantPast))])
        #expect(throws: PlanError.self) { try validator.check(plan) }
    }

    @Test func modelCannotInventAnAction() throws {
        let raw = #"{"steps":[{"action":"delete_files","title":"~","when":"","minutes":0,"body":""}]}"#
        #expect(throws: EngineError.self) { try Engine.decode(raw, When(now: now)) }
    }
}

@Suite struct Effects {
    let plan = Plan(steps: [
        Step(id: "s1", action: .addTask(title: "Send the invoice", due: friday3pm)),
        Step(id: "s2", action: .scheduleEvent(title: "Review", start: friday3pm, minutes: 45), after: ["s1"]),
    ])

    @Test func runningTwiceChangesNothing() throws {
        let workspace = try scratch()
        let executor = Executor(workspace: workspace, now: now)
        try executor.run(plan)
        let second = try executor.run(plan)
        #expect(second.allSatisfy { $0.status == "skipped" })
        #expect(workspace.lines(of: workspace.tasks).count == 1)
    }

    @Test func aCrashNeverDuplicates() throws {
        let workspace = try scratch()
        let crashing = Executor(workspace: workspace, now: now, crashAfter: "s1")
        #expect(throws: Executor.Crash.self) { try crashing.run(plan) }

        try Executor(workspace: workspace, now: now).run(plan)
        #expect(workspace.lines(of: workspace.tasks).count == 1)
    }

    @Test func aTitleCannotEscapeTheWorkspace() {
        #expect(Workspace.slug("../../.ssh/authorized_keys") == "ssh-authorized-keys")
    }
}

@Test func readsTokenCountFromLlamaTimings() {
    let log = """
    prompt eval time =  16.83 ms /    13 tokens
           eval time =  27.04 ms /     7 runs
          total time =  46.43 ms /    20 tokens
    """
    #expect(LlamaProposer.tokens(in: log) == 20)
}

@Suite struct Replay {
    struct Canned: Proposer {
        let name = "canned"
        func propose(_ request: String) async throws -> ModelReply {
            let raw = #"{"steps":[{"action":"add_task","title":"Pay rent","when":"friday at 3pm","minutes":0,"body":""}]}"#
            return ModelReply(raw: raw, tokens: 40)
        }
    }

    @Test func aRecordRebuildsTheSamePlan() async throws {
        let routed = try await Engine(now: now, proposer: Canned()).plan("sort out rent by friday afternoon")
        #expect(routed.route == "canned")
        #expect(routed.cloudTokens == 0)
        #expect(try fingerprint(try Engine.replay(routed)) == routed.hash)
    }
}
swift test
✔ Test run with 13 tests in 5 suites passed after 0.003 seconds.

15. Score it on requests you never tuned for

A test set is one line per request: what should happen, and the date it should land on. act.zip includes two of them, so copy those into your package. A refusal is a right answer when the request is out of scope.

cases.jsonl
{"request":"remind me to send the invoice friday at 3pm","expect":["addTask"],"date":"2026-11-06T15:00:00Z"}
{"request":"nudge me to pay the rent on monday","expect":["addTask"],"date":"2026-11-09T09:00:00Z"}
{"request":"put a 45 minute design review on my calendar for tomorrow at 10am","expect":["scheduleEvent"],"date":"2026-11-06T10:00:00Z"}
{"request":"book me a flight to Lisbon","expect":["refused"]}

The prompt was tuned against cases.jsonl. Gemma 4B scored 33 of 40 on the first prompt, 35 after five examples, and 40 after one sentence about colons. That last number is flattering, because those are the questions it studied. So holdout.jsonl was written afterwards and run with no further changes:

export ACT_MODEL=~/models/gemma-3-4b-it-Q4_K_M.gguf
act eval holdout.jsonl --now 2026-11-05T09:00:00Z
route                     cases  right  avg ms   local tokens
gemma-3-4b-it-Q4_K_M        19     16  1149.97    6802
refused                      8      7     0.00       0
rules                        3      3     0.68       0
26 of 30 right, 0 cloud tokens
wrong:
  I still need to back up the photos
    wanted ["addTask"], got ["refused"]
  log this as Meeting notes: TODO: send the deck
    wanted ["saveNote"], got ["addTask"]
  turn off the lights
    wanted ["refused"], got ["addTask"]
  book me a flight to Lisbon on friday
    wanted ["refused"], got ["scheduleEvent"]
30 held-out requestsRightAverage timeCloud tokens
Rules, no model120.7 ms0
Rules and Gemma 3 1B120.76 s0
Rules and Gemma 3 4B261.15 s0

Measured on an Apple M5 Max with 36 GB. Model times are for requests that reached the model, and include loading it. Gemma 4B used about 358 local tokens per request. Thirty hand-written requests is a smoke test, not a benchmark.

Three things stand out. First, Gemma 1B scored the same as no model at all, so try the smallest model you can, then measure it. Second, the grammar kept every 4B reply well formed, so all four misses are mistakes of meaning, not format. Third, look at this one:

"Book me a flight to Lisbon on friday" became a calendar event. It is valid, well typed, and passes every check, and it is not what was asked. No validator can catch that, because the plan is legal. This is why a person reads the plan before run, and why side effects stay reversible until they do.

Then improve it the cheap way. Every line in .act/audit.jsonl with a model route is a candidate for a new rule. Promote the common shapes, and token use falls as the engine learns your traffic, with no retraining.

Where this stops working

This shape suits a closed set of actions. It is not a replacement for every agent.

  • Open-ended work still needs a big model. Research, writing, and multi-step reasoning do not fit a form with five fields.
  • Rules need upkeep. Each pattern is code you own, in one language. Expect to delete some.
  • The date reader is small on purpose. "The Tuesday after next" gets a refusal, not an answer.
  • A hash is not a signature. It catches a changed plan. It does not stop someone who can rewrite your audit log.
  • Replays depend on the record. A reply is repeatable because it was saved, not because a model will say it again.

End of the line

The rules answered what they could, a local model answered the rest, and no token left the machine. Every signal is green. Go build yours.

Measured in November 2026 on macOS 26.7 with Swift 6.4, llama.cpp build 9770, and Gemma 3 Q4_K_M, under the Gemma terms. Every Swift file on this page was compiled and tested as the package you can download.

Prices and models change. The arithmetic does not. No em-dashes were harmed in the making of this guide.