Skip to content

Cog for coding agents

This is the whole Cog handbook on one page, written for a coding model that is about to write or change code in an app that uses Cog. It states the model, shows the recurring shapes, and lists the conventions the linter checks. When a rule here is too short, the linked handbook chapter is the full version, and the core design records why.

Point your agent here

Agents do not read a dependency's docs unprompted. Give yours one of these:

  • Two lines in AGENTS.md, CLAUDE.md, .github/copilot-instructions.md, or a Cursor rule. The .md URL is this page as plain Markdown.

    markdown
    State management is Cog. Before writing or changing state, read
    https://skeswa.github.io/cog/swift/agent-guide.md and follow it. Run
    `swift package coglint Sources --target-role production` before finishing.

    A Cursor rule can scope itself to the state layer, since every state file follows the rig naming:

    markdown
    ---
    description: Cog state-management conventions
    globs: ["**/*Rig+*.swift", "**/*App.swift"]
    alwaysApply: false
    ---
    
    State management is Cog. Read
    https://skeswa.github.io/cog/swift/agent-guide.md and follow it.
  • The skill. npx skills add skeswa/cog installs this page and the handbook chapters as an agent skill for Claude Code, Codex, Cursor, Copilot, Gemini CLI, and others. In Claude Code, /plugin marketplace add skeswa/cog then /plugin install cog@cog does the same as a plugin.

  • The whole site. https://skeswa.github.io/cog/llms.txt indexes every page's Markdown twin, including the API reference and the lint rule articles that coglint diagnostics link to. Agents using the Context7 MCP server find the handbook under the skeswa/cog library.

The model in seven lines

  1. One app-wide graph, Cogs, assembled once at launch. Everything in it runs on the MainActor.
  2. State is declared as file-scope lets. A manual declaration is a writable source. An automatic declaration is a cached derived value. An async declaration is a value that arrives from outside the process. Each comes keyless (Cog) or keyed (CogBox).
  3. Reads are subscripts. cogs[x] in a view and c[x] in a computation are tracked. peek(x) is a one-time read that records no dependency.
  4. Writes happen only inside named operations on CogOps. One op is one atomic turn.
  5. App-wide side effects live in Mechanisms registered at assembly.
  6. SwiftUI bindings are thin tracked adapters on Cogs, one file per rig.
  7. Tests and previews each create one isolated Cogs.forTesting().

Isolation

Every declaration and op below is MainActor-isolated. The example apps get that from the target's default actor isolation setting. If the target you are editing does not default to MainActor, annotate each file-scope declaration with @MainActor, as the getting-started tutorial does.

File layout

State lives in rigs. A rig is one <Rig>Rig prefix and the four files that share it, <Rig>Rig+<Aspect>.swift. A small app has one rig. Add rigs as the app grows; do not grow the files.

FileHolds
+Model.swiftValue types: identities, records, snapshots, capability structs
+Cogs.swiftSources, projections, automatic and async cogs, CogOps ops
+Bindings.swiftSwiftUI Binding adapters on Cogs
+Mechanisms.swiftMechanisms and the capabilities they own

Immutable content that never enters the graph stays outside the rig.

Recipes

Declare a writable fact

The source is private and underscored. The .readOnly projection takes the clean name and is what the rest of the app reads. The initial value is a closure.

swift
/// The selected visibility filter.
private let _todoFilterCog = Cog<TodoFilter>.Manual { .all }

/// Read-only selected filter.
let todoFilterCog = _todoFilterCog.readOnly

Derive a value

Automatic cogs are cached, recompute only when a dependency changes, and are equality-gated. Unwrap every read into a local named after the declaration minus its Cog or Cogs suffix.

swift
/// Number of incomplete todos.
let activeTodoCountCog = Cog<Int> { c in
  let todoIDs = c[todoIDsCog]
  return todoIDs.reduce(into: 0) { count, id in
    let todoIsCompleted = c[todoIsCompletedCogs[id]]
    if !todoIsCompleted { count += 1 }
  }
}

Never compute a derived value inline in several views. Never bundle several reads into a struct or a Cogs helper to imitate one. Declare the cog.

Keep per-row state keyed

Membership is one keyless ordered value. Each row field is a keyed box, so editing one row invalidates one row.

swift
private let _todoIDsCog = Cog<[TodoID]>.Manual { [] }
private let _todoTitleCogs = CogBox<String, TodoID>.Manual { "" }
private let _todoIsCompletedCogs = CogBox<Bool, TodoID>.Manual { false }

let todoIDsCog = _todoIDsCog.readOnly
let todoTitleCogs = _todoTitleCogs.readOnly
let todoIsCompletedCogs = _todoIsCompletedCogs.readOnly

/// Whether one trail is bookmarked, keyed so one toggle updates one row.
let isTrailSavedCogs = CogBox<Bool, TrailID> { c, trailID in
  let savedTrailIDs = c[savedTrailIDsCog]
  return savedTrailIDs.contains(trailID)
}

Load data from outside the process

An async declaration has a required default:. It selects its dependencies synchronously, then returns work that runs off the MainActor. Inject the service through a cog so tests can replace it.

swift
private let _weatherServiceCog = Cog<WeatherService>.Manual { .live }
let weatherServiceCog = _weatherServiceCog.readOnly

let weatherForecastCogs = CogBox<WeatherReading?, ZipCode>.Async(default: nil) { c, zip in
  let weatherService = c[weatherServiceCog]
  return .run { @concurrent in
    try await weatherService.forecast(for: zip)
  }
}

A plain read is total: it returns the last accepted value, or the default before one exists. Only code that draws spinners or errors opts into the status lens, and it keeps the same local name.

swift
let weatherForecast = cogs[weatherForecastCogs[zip]]          // WeatherReading?
let forecast = cogs.status[weatherForecastCogs[zip]]          // CogStatus
if forecast.isLoading { ProgressView() }
if let error = forecast.error { Text(error.localizedDescription) }

Read only the status fields you use: kind, value, hasSucceeded, error, isLoading. Derive automatic values from the plain read, not the status, so they stay calm across reloads.

Write through a named op

turn and refresh are primitives. App code never calls them inline. Every mutation is a verb in an extension CogOps, defined in the same file as the sources it writes. One outer turn is one atomic turn, however many sources it touches.

swift
extension CogOps {
  /// Selects the visibility filter.
  func selectTodoFilter(_ filter: TodoFilter) {
    turn(_todoFilterCog, to: filter)
  }

  /// Commits the composer as a new row and clears it in one turn.
  func addTodo(id: TodoID = TodoID()) {
    turn { c in
      let newTodoTitle = c[_newTodoTitleCog].normalizedTodoTitle
      guard !newTodoTitle.isEmpty else { return }
      c[_todoIDsCog] = c[_todoIDsCog] + [id]
      c[_todoTitleCogs[id]] = newTodoTitle
      c[_todoIsCompletedCogs[id]] = false
      c[_newTodoTitleCog] = ""
    }
  }

  /// Demands a fresh forecast for one ZIP.
  func refreshForecast(for zip: ZipCode) {
    refresh(weatherForecastCogs[zip])
  }
}

Because ops extend CogOps, the same verb works on a view's cogs, a mechanism's controller m, and a gated scope's sub-controller s.

Inside a turn body, later lines see earlier writes. Writing an equal value is discarded. A turn body must not await, and an automatic computation must not write.

Compose ops across files

Another rig's sources are private, so call its op inside your turn body. The nested turn joins the outer one and both publish together.

swift
/// Commits a hike entry and dismisses the logger in one settled turn.
func logHike(for trailID: TrailID, note: String) {
  turn { c in
    c[_hikeEntriesCog] = [HikeEntry(trailID: trailID, note: note)] + c[_hikeEntriesCog]
    self.dismissSheet()
  }
}

Read in a view

Every Cog-using view resolves the runtime from the environment itself. It never accepts, stores, or forwards Cogs. Parents pass identities and plain values only. Reads are one per line, each unwrapped into a local, and the body calls ops for actions.

swift
struct TodoRow: View {
  @Environment(\.cogs) private var cogs
  let id: TodoID

  var body: some View {
    let todoTitle = cogs[todoTitleCogs[id]]
    let todoIsCompleted = cogs[todoIsCompletedCogs[id]]

    HStack {
      Button { cogs.toggleTodo(id) } label: {
        Image(systemName: todoIsCompleted ? "checkmark.circle.fill" : "circle")
      }
      Text(todoTitle)
    }
  }
}

Uncommitted drafts and platform-only presentation state stay in @State. The graph holds committed facts. If no other screen, mechanism, or persistence would care about a value, it is view state.

Bind a SwiftUI control

Cog ships no binding helper. Write one adapter per control in the rig's +Bindings.swift: a tracked getter through self[…], a setter that calls a named op. Never build a Binding inside a view, and never read the getter with peek.

swift
extension Cogs {
  /// Tracked binding for the new-todo composer.
  var newTodoTitleBinding: Binding<String> {
    Binding(
      get: {
        let newTodoTitle = self[newTodoTitleCog]
        return newTodoTitle
      },
      set: { self.typeNewTodoTitle($0) }
    )
  }

  /// Tracked binding for the presented sheet; `nil` dismisses.
  var presentedSheetBinding: Binding<Sheet?> {
    Binding(
      get: {
        let presentedSheet = self[presentedSheetCog]
        return presentedSheet
      },
      set: { sheet in
        if let sheet {
          self.present(sheet)
        } else {
          self.dismissSheet()
        }
      }
    )
  }
}
swift
TextField("What needs to be done?", text: cogs.newTodoTitleBinding)
  .sheet(item: cogs.presentedSheetBinding) { sheet in SheetContent(sheet: sheet) }

Assemble once, at the entry point

The entry point assembles and retains. It does not read or write. Mechanisms run in array order, and their operate writes finish before assemble returns.

swift
@main
@MainActor
struct TodoApp: App {
  private let cogs: Cogs

  init() {
    cogs = Cogs.assemble(mechanisms: [
      TodoMechanism(store: .live)
    ])
  }

  var body: some Scene {
    WindowGroup {
      TodoRoot()
        .cogEnvironment(cogs)
    }
  }
}

A second assemble traps. There is no Cogs.app global.

Put initial state and side effects in a mechanism

A mechanism owns its capabilities as injected stored properties, names every registration, and captures its controller weakly in anything long-lived. Initial app state is written in operate. This is also the persistence pattern: one snapshot cog, one store capability, install then watch.

swift
struct TodoMechanism: Mechanism {
  let store: TodoStore

  func operate(_ m: MechanismController) {
    let snapshot = store.load() ?? TodoSnapshot(todos: [], filter: .all)
    m.installTodos(snapshot.todos, filter: snapshot.filter)

    m.watch(todoSnapshotCog, initial: .skip, name: "persistence") { _, snapshot in
      store.save(snapshot)
    }
  }
}

A capability is a small struct with closure fields and a .live value, so a test passes TodoStore(load: { nil }, save: { saved.append($0) }) with no mocking framework. Storage is read once at assembly and then only written. It is never a second live source.

A write from a watch handler becomes its own later turn. It does not join the turn it observed.

Tie work to a fact with a scope

Use .scope(...) to start and stop work as state changes. Use .discard(...) to release saved state when the app is finished with it. For example, closing a trail screen can stop its timer and release its unfinished note. Ending the scope handles the timer; the closing op must also discard the note.

Start with a Bool for work that runs while a condition is true:

swift
struct HikeTimerMechanism: Mechanism {
  var clock: any Clock<Duration> = ContinuousClock()

  func operate(_ m: MechanismController) {
    m.scope(isLoggingHikeCog, name: "hikeTimer") { s in
      s.resetHikeTimer()
      s.task(name: "tick") { [weak s] in
        while true {
          try await clock.sleep(for: .seconds(1))
          guard let s else { return }
          await s.tickHikeTimer()
        }
      }
    }
  }
}

When isLoggingHikeCog becomes true, the body runs once with controller s. Register the timer through s so it ends with that scope. When the Bool becomes false, Cog cancels the timer. Opening again runs the body with a new controller. The timer resets because the body calls resetHikeTimer(); scopes do not reset graph state themselves.

Derive the Bool from navigation state so a swipe to dismiss the logger stops the timer just as a Close button does.

For work tied to one particular screen, select an optional ID. This sketch watches that screen's filter:

swift
// activeTrailScreenCog holds a TrailScreenID? value.
m.scope(activeTrailScreenCog, name: "trailScreen") { screenID, s in
  s.watch(trailFilterCogs[screenID], initial: .skip, name: "filter") { _, filter in
    analytics.record(.filterChanged(filter), screen: screenID)
  }
}

nil means no screen and no work. Changing A to B stops A's scope and starts B's. Keeping A keeps the same work running. A Bool cannot express this replacement: true stays true when the user switches screens.

Create an ID in the op that opens a screen or signs in a session, then keep it in state. Never create it inside an automatic selector. Give two openings of the same trail different screen IDs so they can own separate work.

For several open screens, use one registration over their ID array:

swift
m.scope(each: openTrailScreensCog, name: "trailScreen") { screenID, s in
  s.watch(trailFilterCogs[screenID], initial: .skip, name: "filter") { _, filter in
    analytics.record(.filterChanged(filter), screen: screenID)
  }
}

Adding B to [A] starts B and keeps A running. Removing A stops only A. Reordering the array restarts nothing. IDs must be unique; duplicates cause a runtime error. All scope forms use the final value from each turn, so removing and restoring an ID within one turn does not restart its scope.

The handbook shows the scope forms step by step.

Async work may finish after a scope ends. Cog disables that scope's controller, which the API calls retirement: turn does nothing (including a turn already queued), registrations start nothing, and task returns an already-cancelled task. peek, status.peek, and refresh cause a runtime error. Publish results through a named op that checks the requesting screen's ID inside the writer body:

swift
extension CogOps {
  func acceptTrail(_ trail: Trail, receipt: TrailScreenID) {
    turn { c in
      guard c[_openTrailScreensCog].contains(receipt) else { return }
      c[_loadedTrailCogs[receipt]] = trail
    }
  }
}

When a late completion truly must read, wrap that read in s.ifLive { $0.peek(...) }, which returns nil instead of trapping. It checks once, at the call; it reserves nothing, so re-check after every await.

Task closures are nonisolated, so await op calls from them. In the scope body, use peek for one-time reads; those reads do not restart the scope. Inject clocks so tests can drive time.

Release a closed screen's state with discard

Ending a scope stops its work but does not clear its values. Cog cannot tell when the last SwiftUI view stops reading a value, so values a view has read stay in memory until the app releases them.

Declare temporary manual state so it can start over after release:

swift
private let _trailFilterCogs = CogBox<String, TrailScreenID>.Manual(
  { "" },
  lifetime: .whileObserved(resetToInitial: true)
)
let trailFilterCogs = _trailFilterCogs.readOnly

Then remove the screen ID and discard its filter in the closing op:

swift
extension CogOps {
  func closeTrailScreen(_ id: TrailScreenID) {
    turn { c in c[_openTrailScreensCog].removeAll { $0 == id } }
    discard(_trailFilterCogs[id])
  }
}

Call this through cogs or a controller that outlives the screen. The screen's own controller stops accepting discard calls once its scope ends.

discard(_trailFilterCogs[id]) releases only that screen's filter. If it was "easy", a later read creates it again as "". Other screens' filters stay unchanged. Any view still reading the released value is notified after release.

Manual state needs .whileObserved(resetToInitial: true); discarding existing .app state causes a runtime error. Pass the manual source, not its read-only projection. A synchronous automatic cog can also be discarded and recomputes on its next read. Async cogs have no discard overload.

A watch, exported stream, or another cog can keep a value in use; discard leaves it alone in that case. Each discard runs as its own turn at the next safe graph boundary. Keep it after the turn that ends the screen's watches. Leave shared trail data out of this cleanup. For a Clear button on an open screen, write an empty string in a normal turn instead.

See the full closing example. Effects that matter only while one view is visible use SwiftUI's .task.

Drive navigation from state

There is no router. Each container has one manual source in the navigation rig: an enum for the tab, a keyed box of route arrays for the stacks, one optional enum for modality. Routes carry identities, never loaded models.

swift
private let _selectedTabCog = Cog<AppTab>.Manual { .home }
private let _tabPathCogs = CogBox<[Route], AppTab>.Manual { [] }
private let _presentedSheetCog = Cog<Sheet?>.Manual { nil }

extension CogOps {
  /// Selects a tab; reselecting the current tab pops it to its root.
  func selectTab(_ tab: AppTab) {
    turn { c in
      if c[_selectedTabCog] == tab {
        c[_tabPathCogs[tab]] = []
      } else {
        c[_selectedTabCog] = tab
      }
    }
  }

  /// Opens a deep link by writing the whole destination in one turn.
  func open(_ link: DeepLink) {
    switch link {
    case .detail(let itemID):
      guard let item = catalog.item(itemID) else { return }
      turn { c in
        c[_selectedTabCog] = .home
        c[_tabPathCogs[AppTab.home]] = [.collection(item.collectionID), .detail(itemID)]
        c[_presentedSheetCog] = nil
      }
    }
  }
}

Buttons call these ops. Back gestures, tab taps, and swipe-to-dismiss reach the same ops through the binding adapters. Derive the current screen as an automatic cog and hang analytics or gated work off it. Navigation state goes into the same persisted snapshot as the domain.

Test without a UI

Drive the state layer through the same ops production calls, then assert with peek. Use seeding: to place values with no turn and no reaction. Use mechanisms: when the mechanism is the thing under test.

swift
import Cog
import CogTesting
import Testing

@MainActor
@Test func addingATodoClearsTheComposer() {
  let cogs = Cogs.forTesting()

  cogs.typeNewTodoTitle("Buy milk")
  cogs.addTodo(id: TodoID(rawValue: "milk"))

  #expect(cogs.peek(todoIDsCog) == [TodoID(rawValue: "milk")])
  #expect(cogs.peek(newTodoTitleCog) == "")
}

@MainActor
@Test func restoresTheSavedDocument() {
  var saved: [TodoSnapshot] = []
  let cogs = Cogs.forTesting(mechanisms: [
    TodoMechanism(
      store: TodoStore(load: { .fixture }, save: { saved.append($0) })
    )
  ])

  #expect(cogs.peek(todoIDsCog) == TodoSnapshot.fixture.todos.map(\.id))
}

Time is injected: pass a TestClock to the mechanism and advance it. Async work under test uses ControlledWork or ControlledStream from CogTesting, and awaits starts before completing a generation. Views under test and in previews are hosted with .cogEnvironment(cogs) over one isolated runtime.

Conventions

Each line is enforced by the named coglint rule where one exists.

DoNeverRule
Name keyless declarations …Cog, keyed boxes …Cogs, qualifiers before the suffix…Source, …State, or no suffix on a graph referencecog-declaration-suffix
Declare manual sources privateExpose a writable sourcemanual-cog-private
Start a manual source with _; the .readOnly projection drops itGive the projection a different namemanual-cog-underscore
Call turn, refresh, and discard only inside extension CogOpscogs.turn { … } in a view, mechanism, or extension Cogsprimitives-only-in-ops
Write initial state in a mechanism's operateRead or write the graph in App.initinitial-state-in-mechanism
Resolve @Environment(\.cogs) in every Cog-using viewPass Cogs through a view initializer or store itno-cogs-in-view-init
Read flatly, one line per read, unwrapped into a domain localA Cogs helper or struct that packages several readsno-multi-read-cogs-helper
Build bindings as tracked adapters on Cogs in +Bindings.swiftBinding(get:set:) inside a view, or a getter that uses peektracked-binding-adapters
One op, one turn; cross-file writes nest another file's opTwo ops for one user action, or one file writing another's source
Initial values are closures: .Manual { 0 }.Manual(0) or a shared reference instance
peek for one-time reads: operate, scope bodies, testspeek in a view body or a computation
Derive from the plain async readDerive from status, which flickers on every reload
Name every watch, scope, and task registrationAnonymous registrations
Inject clocks, stores, and services through stored properties or cogsContinuousClock() or a live service hard-coded inside a body
One Cogs.forTesting() per test or previewA second runtime in the same test tree, or assemble in a test

Lint

coglint runs the rules above as build errors. It is distributed as a separate package pinned to exactly the same version as Cog, so an app that does not want it never fetches the binary.

Add it beside Cog and attach the build-tool plugin to each source target:

swift
dependencies: [
  .package(url: "https://github.com/skeswa/cog.git", .upToNextMinor(from: "0.8.1")),
  .package(url: "https://github.com/skeswa/coglint-plugins.git", exact: "0.8.1"),
],
targets: [
  .target(
    name: "Forecast",
    dependencies: [.product(name: "Cog", package: "cog")],
    plugins: [.plugin(name: "CogLintBuildToolPlugin", package: "coglint-plugins")]
  ),
]

Run it on demand, with production and test sources in separate invocations so each gets its role:

console
swift package coglint Sources --target-role production --reporter xcode
swift package coglint Tests --target-role test --reporter xcode

A finding names its rule and links to the article that shows the repair:

text
WeatherCard.swift:186:7: error: [primitives-only-in-ops] `refresh` is a demand
on the graph; call a named op from a `CogOps` extension —
https://skeswa.github.io/cog/documentation/cog/primitivesonlyinops

Fix the code rather than suppressing. When the exception is the point of the code, suppress the next line only, naming the rule and a reason:

swift
// coglint:disable-next-line primitives-only-in-ops -- low-level boundary proof
c.turn { writer in writer[_countCog] += 1 }

The linter is syntax-only. A test target is exempt from primitives-only-in-ops. Everything else is an error. The full rule reference is Linting your app.

When the page does not cover it

Decide by the four principles, in order: simple to read and reason about; every read correct; minimal overhead; and singular state, meaning one graph, one writable source per fact, no islands and no mirrors. Then check the matching handbook chapter: structure, declaring, reading, writing, SwiftUI, side effects, navigation, and testing. The API reference has every symbol.

Released under the MIT License.