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.
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
The model proposes. It never acts. It fills in a form, and code does everything after that.
Code owns the facts. Dates, paths, ids, counts, and order never come from a model.
Take the cheapest route first. A regular expression beats a model whenever it can.
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.
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.
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.
turn 13,000
turn 23,500
turn 34,000
turn 44,500
turn 55,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.
~/act/01-packagefoundation✓
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.
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
importFoundation/// Everything the engine can ever do. There is no `case other`.publicenumAction: 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)
}
publicstructStep: Codable, Equatable, Sendable {
publicvar id: Stringpublicvar action: Actionpublicvar after: [String]
publicinit(id: String, action: Action, after: [String] = []) {
self.id = id
self.action = action
self.after = after
}
}
publicstructPlan: Codable, Equatable, Sendable {
publicvar steps: [Step]
publicinit(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.
~/act/03-hashfoundation✓
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
importCryptoKitimportFoundation/// One value, one byte sequence: sorted keys, fixed date format.publicfunc canonicalJSON<T: Encodable>(_ value: T) throws -> Data {
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
encoder.dateEncodingStrategy = .iso8601
returntry encoder.encode(value)
}
publicfunc 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()
}
publicfunc decodeCanonical<T: Decodable>(_ type: T.Type, from data: Data) throws -> T {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
returntry 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.
~/act/04-datestier-0✓
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
importFoundation/// Turns "friday at 3pm" into a date. Code owns the calendar, never the model.publicstructWhen: Sendable {
publiclet now: Datepubliclet calendar: Calendarpublicinit(now: Date, timeZone: TimeZone = .gmt) {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = timeZone
self.now = now
self.calendar = calendar
}
staticlet days = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"]
/// The phrase must be a date, a time, or both, and nothing else.publicfunc 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 { returnnil }
var hour = 9, minute = 0
iflet raw = match.hour {
hour = Int(raw)!
minute = match.minute.map { Int($0)! } ?? 0
iflet ampm = match.ampm {
guard (1...12).contains(hour) else { returnnil }
hour = hour % 12 + (ampm == "pm" ? 12 : 0)
} elseif match.minute == nil {
returnnil// "at 3" could be either. Do not guess.
}
}
guard (0...23).contains(hour), (0...59).contains(minute) else { returnnil }
let today = calendar.startOfDay(for: now)
var offset = 0
switch match.day.map(String.init) {
casenil, "today": offset = 0
case"tomorrow": offset = 1
caselet 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.
~/act/05-rulestier-0✓
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
importFoundation/// Tier 0. Known request shapes become plans with no model at all.publicstructRules: Sendable {
publiclet when: Whenpublicinit(when: When) { self.when = when }
publicfunc 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() {
guardlet action = action(part) else { returnnil }
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? {
iflet m = text.wholeMatch(of: /(?i)(?:remind me to|add a task to|add task:?|todo:?)\s+(?<rest>.+)/) {
let (title, phrase) = splitWhen(String(m.rest))
iflet phrase {
guardlet due = when.resolve(phrase) else { returnnil }
return .addTask(title: title, due: due)
}
return .addTask(title: title, due: nil)
}
iflet 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))
guardlet phrase, let start = when.resolve(phrase) else { returnnil }
var minutes = 30
iflet n = m.n.flatMap({ Int($0) }) {
minutes = m.unit?.lowercased().hasPrefix("hour") == true ? n * 60 : n
}
return .scheduleEvent(title: title, start: start, minutes: minutes)
}
iflet 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))
}
iflet m = text.wholeMatch(of: /(?i)extract tasks from\s+(?<a>.+)|turn\s+(?<b>.+)\s+into tasks/) {
return .extractTasks(note: String(m.a ?? m.b ?? ""))
}
returnnil
}
/// 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)?)$/guardlet 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.
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.
~/act/06-contracttier-1✓
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
importFoundation/// What a model may say. Dates stay as words here; code resolves them later.publicstructProposal: Codable, Equatable, Sendable {
publicstructItem: Codable, Equatable, Sendable {
publicvar action: Stringpublicvar title: Stringpublicvar when: Stringpublicvar minutes: Intpublicvar body: String
}
publicvar steps: [Item]
publicstaticlet 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}}}}}} """publicstaticlet 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 """
}
publicenumProposalError: Error, Equatable, CustomStringConvertible {
case declined
case unreadableDate(String)
case missingDate(String)
publicvar description: String {
switchself {
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."
}
}
}
extensionProposal {
/// The bridge from words to a typed plan. Everything past here is plain code.publicfunc 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)
}
returnPlan(steps: result)
}
privatefunc 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":
guardlet 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
}
}
privatefunc date(_ words: String, _ clock: When) throws(ProposalError) -> Date? {
if words.isEmpty { returnnil }
guardlet 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.
~/act/07-gemmatier-1✓
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.
importFoundationpublicstructModelReply: Sendable {
publicvar raw: Stringpublicvar tokens: Int
}
publicprotocolProposer: Sendable {
var name: String { get }
func propose(_ request: String) asyncthrows -> ModelReply
}
/// Runs any GGUF model on this machine. The grammar comes from the JSON schema,/// so tokens that would break it are never sampled.publicstructLlamaProposer: Proposer {
publiclet modelPath: Stringpublicvar name: String { URL(filePath: modelPath).deletingPathExtension().lastPathComponent }
publicinit(modelPath: String) { self.modelPath = modelPath }
publicfunc propose(_ request: String) asyncthrows -> 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 = tryFileHandle(forWritingTo: log)
process.standardInput = FileHandle.nullDevice
try process.run()
let data = output.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
let text = String(decoding: data, as: UTF8.self)
guardlet start = text.firstIndex(of: "{"), let end = text.lastIndex(of: "}") else {
throwCocoaError(.coderReadCorrupt)
}
let perf = (try? String(contentsOf: log, encoding: .utf8)) ?? ""returnModelReply(raw: String(text[start...end]), tokens: Self.tokens(in: perf))
}
/// llama.cpp ends its timings with "total time = ... / 412 tokens".staticfunc 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 schema
With grammar
Free text
Gemma 3 4B
30 of 30
14 of 30
Gemma 3 1B
30 of 30
0 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.
~/act/08-appletier-1✓
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)
importFoundationimportFoundationModels/// The same contract on Apple's built-in model. Nothing to download.@available(macOS 26, iOS 26, *)
publicstructAppleProposer: Proposer {
@GenerablestructDraft {
@GenerablestructItem {
@Guide(.anyOf(["add_task", "schedule_event", "save_note", "extract_tasks", "none"]))
var action: Stringvar title: String@Guide(description: "The date in the user's words, or empty")
var when: String@Guide(.range(0...480))
var minutes: Intvar body: String
}
@Guide(.count(1...4))
var steps: [Item]
}
publiclet name = "apple-on-device"publicinit() {}
publicstaticvar isAvailable: Bool {
ifcase .available = SystemLanguageModel.default.availability { returntrue }
returnfalse
}
publicfunc propose(_ request: String) asyncthrows -> ModelReply {
let session = LanguageModelSession(instructions: Proposal.instructions)
let options = GenerationOptions(samplingMode: .greedy)
let draft = tryawait 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)
returnModelReply(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.
~/act/09-gateinterlock✓
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
importFoundationpublicenumPlanError: 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)
publicvar description: String {
switchself {
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)"
}
}
}
publicstructValidator: Sendable {
publicstaticlet maxSteps = 8
publiclet now: Datepublicinit(now: Date) { self.now = now }
/// Returns the steps in the order they must run, or the reason they cannot.publicfunc 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)
}
}
returntry 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)
iflet 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 {
guardlet 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.
Refused. The request is outside the four things this engine can do.
~/act/10-routerinterlock✓
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
importFoundationpublicstructRouted: Codable, Equatable, Sendable {
publicvar request: Stringpublicvar now: Datepublicvar route: Stringpublicvar plan: Planpublicvar hash: Stringpublicvar raw: String?
publicvar localTokens: Intpublicvar cloudTokens: Intpublicvar milliseconds: Double
}
publicenumEngineError: Error, CustomStringConvertible {
case noRoute
case rejected(String)
publicvar description: String {
switchself {
case .noRoute: "No rule matched, and no local model is set."case .rejected(let reason): reason
}
}
}
publicstructEngine: Sendable {
publiclet now: Datepubliclet timeZone: TimeZonepubliclet proposer: (any Proposer)?
publicinit(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.publicfunc plan(_ request: String) asyncthrows -> Routed {
let clock = When(now: now, timeZone: timeZone)
let started = ContinuousClock.now
iflet plan = Rules(when: clock).plan(request) {
returntry seal(request, "rules", plan, nil, 0, started)
}
guardlet proposer else { throwEngineError.noRoute }
let reply = tryawait proposer.propose(request)
let plan = trySelf.decode(reply.raw, clock)
returntry seal(request, proposer.name, plan, reply.raw, reply.tokens, started)
}
/// Rebuilds a plan from a record without running any model.publicstaticfunc replay(_ record: Routed, timeZone: TimeZone = .gmt) throws -> Plan {
let clock = When(now: record.now, timeZone: timeZone)
iflet raw = record.raw { returntry decode(raw, clock) }
guardlet plan = Rules(when: clock).plan(record.request) else { throwEngineError.noRoute }
return plan
}
staticfunc decode(_ raw: String, _ clock: When) throws -> Plan {
let proposal = tryJSONDecoder().decode(Proposal.self, from: Data(raw.utf8))
do {
returntry proposal.plan(when: clock)
} catch {
throwEngineError.rejected(error.description)
}
}
privatefunc seal(
_ request: String, _ route: String, _ plan: Plan, _ raw: String?, _ tokens: Int,
_ started: ContinuousClock.Instant
) throws -> Routed {
do {
_ = tryValidator(now: now).check(plan)
} catch {
throwEngineError.rejected(error.description)
}
let elapsed = started.duration(to: .now).components
let ms = Double(elapsed.seconds) * 1_000 + Double(elapsed.attoseconds) / 1e15
returnRouted(
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.
~/act/11-effectseffects✓
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
importFoundation/// Every file the engine touches lives under one root.publicstructWorkspace: Sendable {
publiclet root: URLpublicinit(root: URL) throws {
self.root = root
for folder in ["notes", "events", "plans"] {
tryFileManager.default.createDirectory(
at: root.appending(path: folder), withIntermediateDirectories: true)
}
}
publicvar tasks: URL { root.appending(path: "tasks.md") }
publicvar journal: URL { root.appending(path: "journal.jsonl") }
publicvar audit: URL { root.appending(path: "audit.jsonl") }
publicfunc note(_ title: String) -> URL { root.appending(path: "notes/\(Self.slug(title)).md") }
publicfunc plan(_ hash: String) -> URL { root.appending(path: "plans/\(hash).json") }
/// Letters, digits, and hyphens only, so a title can never become a path.publicstaticfunc slug(_ text: String) -> String {
let words = text.lowercased().split { !($0.isASCII && ($0.isLetter || $0.isNumber)) }
return words.isEmpty ? "untitled" : words.joined(separator: "-")
}
publicfunc append(_ line: Data, to file: URL) throws {
if !FileManager.default.fileExists(atPath: file.path) {
FileManager.default.createFile(atPath: file.path, contents: nil)
}
let handle = tryFileHandle(forWritingTo: file)
defer { try? handle.close() }
try handle.seekToEnd()
try handle.write(contentsOf: line + Data("\n".utf8))
}
publicfunc lines(of file: URL) -> [String] {
guardlet 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
importFoundationpublicenumHandlerError: Error, CustomStringConvertible {
case noteExists(String)
case noteMissing(String)
publicvar description: String {
switchself {
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.structHandlers {
let workspace: Workspacefunc perform(_ action: Action, key: String) throws -> String {
switch action {
case .addTask(let title, let due):
returntry addTask(title, due: due, key: key)
case .scheduleEvent(let title, let start, let minutes):
returntry writeEvent(title, start: start, minutes: minutes, key: key)
case .saveNote(let title, let body):
returntry saveNote(title, body: body)
case .extractTasks(let note):
returntry 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"tryData(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"iflet existing = try? String(contentsOf: file, encoding: .utf8) {
guard existing == content else { throwHandlerError.noteExists(title) }
return"notes/\(file.lastPathComponent) (already there)"
}
tryData(content.utf8).write(to: file, options: .atomic)
return"notes/\(file.lastPathComponent)"
}
func extractTasks(from note: String, key: String) throws -> String {
guardlet text = try? String(contentsOf: workspace.note(note), encoding: .utf8) else {
throwHandlerError.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))"
}
}
~/act/12-journaleffects✓
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
importFoundationpublicstructEntry: Codable, Equatable, Sendable {
publicvar key: Stringpublicvar step: Stringpublicvar status: Stringpublicvar detail: String
}
publicstructExecutor: Sendable {
publiclet workspace: Workspacepubliclet now: Date/// Tests use this to crash after a side effect, before it is recorded.publicvar crashAfter: String?
publicinit(workspace: Workspace, now: Date, crashAfter: String? = nil) {
self.workspace = workspace
self.now = now
self.crashAfter = crashAfter
}
publicstructCrash: Error {}
/// Runs a plan in dependency order. Finished steps are never run again.@discardableResultpublicfunc run(_ plan: Plan) throws -> [Entry] {
let order = tryValidator(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 { throwCrash() }
let entry = Entry(key: key, step: step.id, status: "done", detail: detail)
try record(entry)
results.append(entry)
} catchisCrash {
throwCrash()
} catch {
let reason = String(describing: error)
try record(Entry(key: key, step: step.id, status: "failed", detail: reason))
throwEngineError.rejected(reason)
}
}
return results
}
func record(_ entry: Entry) throws {
try workspace.append(try canonicalJSON(entry), to: workspace.journal)
}
publicfunc 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.
~/act/13-clieffects✓
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
importActionEngineimportFoundationlet 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
@MainActorfunc option(_ name: String) -> String? {
guardlet index = arguments.firstIndex(of: name), index + 1 < arguments.count else { returnnil }
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 = tryWorkspace(root: URL(filePath: environment["ACT_HOME"] ?? ".act"))
func proposer() -> (any Proposer)? {
iflet path = environment["ACT_MODEL"] { returnLlamaProposer(modelPath: path) }
#if canImport(FoundationModels)
ifAppleProposer.isAvailable { returnAppleProposer() }
#endifreturnnil
}
func describe(_ action: Action) -> String {
let data = (try? canonicalJSON(action)) ?? Data()
returnString(decoding: data, as: UTF8.self)
}
@MainActorfunc 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 }
}
guardlet 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 = tryawait 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 = tryData(contentsOf: workspace.plan(input))
let plan = try decodeCanonical(Plan.self, from: data)
guardtry fingerprint(plan) == input else { fail("this plan changed after you read it.") }
for entry intryExecutor(workspace: workspace, now: now).run(plan) {
print("\(entry.step) \(entry.status) \(entry.detail)")
}
case"replay":
guardlet record = try load(input) else { fail("no record of \(input).") }
let plan = tryEngine.replay(record, timeZone: timeZone)
print(try fingerprint(plan) == input ? "identical \(input)" : "different \(input)")
case"eval":
let report = tryawaitEvaluation.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
importFoundationpublicstructCase: Codable, Sendable {
publicvar request: Stringpublicvar expect: [String]
publicvar date: Date?
}
publicenumEvaluation {
publicstaticfunc kind(_ action: Action) -> String {
switch action {
case .addTask: "addTask"case .scheduleEvent: "scheduleEvent"case .saveNote: "saveNote"case .extractTasks: "extractTasks"
}
}
staticfunc date(_ action: Action) -> Date? {
switch action {
case .addTask(_, let due): due
case .scheduleEvent(_, let start, _): start
default: nil
}
}
publicstaticfunc run(file: URL, engine: Engine) asyncthrows -> String {
let text = tryString(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 = falsevar ms = 0.0, tokens = 0
do {
let routed = tryawait 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
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:
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.
~/act/14-testsproof✓
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
importFoundationimportTesting@testableimportActionEnginelet 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 {
tryWorkspace(root: FileManager.default.temporaryDirectory.appending(path: UUID().uuidString))
}
@SuitestructDates {
let when = When(now: now)
@Testfunc resolvesWords() {
#expect(when.resolve("friday at 3pm") == friday3pm)
#expect(when.resolve("tomorrow 15:00") == friday3pm)
}
@Testfunc refusesToGuess() {
#expect(when.resolve("at 3") == nil)
#expect(when.resolve("when I get in") == nil)
#expect(when.resolve("13pm") == nil)
}
}
@SuitestructTier0 {
let rules = Rules(when: When(now: now))
@Testfunc 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))])
}
@Testfunc 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"])
}
@Testfunc 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)
}
}
@SuitestructGate {
let validator = Validator(now: now)
@Testfunc 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) }
}
@Testfunc catchesThePast() {
let plan = Plan(steps: [Step(id: "a", action: .addTask(title: "Late", due: .distantPast))])
#expect(throws: PlanError.self) { try validator.check(plan) }
}
@Testfunc modelCannotInventAnAction() throws {
let raw = #"{"steps":[{"action":"delete_files","title":"~","when":"","minutes":0,"body":""}]}"##expect(throws: EngineError.self) { tryEngine.decode(raw, When(now: now)) }
}
}
@SuitestructEffects {
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"]),
])
@Testfunc 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)
}
@Testfunc aCrashNeverDuplicates() throws {
let workspace = try scratch()
let crashing = Executor(workspace: workspace, now: now, crashAfter: "s1")
#expect(throws: Executor.Crash.self) { try crashing.run(plan) }
tryExecutor(workspace: workspace, now: now).run(plan)
#expect(workspace.lines(of: workspace.tasks).count == 1)
}
@Testfunc aTitleCannotEscapeTheWorkspace() {
#expect(Workspace.slug("../../.ssh/authorized_keys") == "ssh-authorized-keys")
}
}
@Testfunc 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)
}
@SuitestructReplay {
structCanned: Proposer {
let name = "canned"func propose(_ request: String) asyncthrows -> ModelReply {
let raw = #"{"steps":[{"action":"add_task","title":"Pay rent","when":"friday at 3pm","minutes":0,"body":""}]}"#returnModelReply(raw: raw, tokens: 40)
}
}
@Testfunc aRecordRebuildsTheSamePlan() asyncthrows {
let routed = tryawaitEngine(now: now, proposer: Canned()).plan("sort out rent by friday afternoon")
#expect(routed.route == "canned")
#expect(routed.cloudTokens == 0)
#expect(try fingerprint(tryEngine.replay(routed)) == routed.hash)
}
}
swift test
✔ Test run with 13 tests in 5 suites passed after 0.003 seconds.
~/act/15-evalsproof✓
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:
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 requests
Right
Average time
Cloud tokens
Rules, no model
12
0.7 ms
0
Rules and Gemma 3 1B
12
0.76 s
0
Rules and Gemma 3 4B
26
1.15 s
0
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.