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.mdURL is this page as plain Markdown.markdownState 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/coginstalls 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/cogthen/plugin install cog@cogdoes the same as a plugin.The whole site.
https://skeswa.github.io/cog/llms.txtindexes every page's Markdown twin, including the API reference and the lint rule articles thatcoglintdiagnostics link to. Agents using the Context7 MCP server find the handbook under theskeswa/coglibrary.
The model in seven lines
- One app-wide graph,
Cogs, assembled once at launch. Everything in it runs on the MainActor. - 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). - Reads are subscripts.
cogs[x]in a view andc[x]in a computation are tracked.peek(x)is a one-time read that records no dependency. - Writes happen only inside named operations on
CogOps. One op is one atomicturn. - App-wide side effects live in
Mechanisms registered at assembly. - SwiftUI bindings are thin tracked adapters on
Cogs, one file per rig. - 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.
| File | Holds |
|---|---|
+Model.swift | Value types: identities, records, snapshots, capability structs |
+Cogs.swift | Sources, projections, automatic and async cogs, CogOps ops |
+Bindings.swift | SwiftUI Binding adapters on Cogs |
+Mechanisms.swift | Mechanisms 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.
/// The selected visibility filter.
private let _todoFilterCog = Cog<TodoFilter>.Manual { .all }
/// Read-only selected filter.
let todoFilterCog = _todoFilterCog.readOnlyDerive 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.
/// 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.
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.
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.
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.
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.
/// 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.
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.
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()
}
}
)
}
}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.
@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.
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:
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:
// 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:
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:
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:
private let _trailFilterCogs = CogBox<String, TrailScreenID>.Manual(
{ "" },
lifetime: .whileObserved(resetToInitial: true)
)
let trailFilterCogs = _trailFilterCogs.readOnlyThen remove the screen ID and discard its filter in the closing op:
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.
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.
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.
| Do | Never | Rule |
|---|---|---|
Name keyless declarations …Cog, keyed boxes …Cogs, qualifiers before the suffix | …Source, …State, or no suffix on a graph reference | cog-declaration-suffix |
Declare manual sources private | Expose a writable source | manual-cog-private |
Start a manual source with _; the .readOnly projection drops it | Give the projection a different name | manual-cog-underscore |
Call turn, refresh, and discard only inside extension CogOps | cogs.turn { … } in a view, mechanism, or extension Cogs | primitives-only-in-ops |
Write initial state in a mechanism's operate | Read or write the graph in App.init | initial-state-in-mechanism |
Resolve @Environment(\.cogs) in every Cog-using view | Pass Cogs through a view initializer or store it | no-cogs-in-view-init |
| Read flatly, one line per read, unwrapped into a domain local | A Cogs helper or struct that packages several reads | no-multi-read-cogs-helper |
Build bindings as tracked adapters on Cogs in +Bindings.swift | Binding(get:set:) inside a view, or a getter that uses peek | tracked-binding-adapters |
| One op, one turn; cross-file writes nest another file's op | Two 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, tests | peek in a view body or a computation | |
| Derive from the plain async read | Derive from status, which flickers on every reload | |
Name every watch, scope, and task registration | Anonymous registrations | |
| Inject clocks, stores, and services through stored properties or cogs | ContinuousClock() or a live service hard-coded inside a body | |
One Cogs.forTesting() per test or preview | A 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:
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:
swift package coglint Sources --target-role production --reporter xcode
swift package coglint Tests --target-role test --reporter xcodeA finding names its rule and links to the article that shows the repair:
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/primitivesonlyinopsFix 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:
// 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.