Agent DB & Self-Scheduling

June 2, 2026 · View on GitHub

Every Osaurus agent can opt into its own private, encrypted SQLite database and a single self-scheduled "next run" slot. Together they turn the agent from a stateless chat into something that remembers structured data across runs and wakes itself up to act on it — a journal that can also set its own alarm.

This is distinct from Memory. Memory is a global, app-wide system that distills conversational context across all your chats; Agent DB is per-agent structured storage the agent decides on, schemas and queries via dedicated tools. You can run an agent with one, both, or neither.

This doc is the reference for developers and power users. It covers the on-disk layout, the db_* tool surface, the next-run scheduler, the four schedule-mode presets, and the detail-view tabs that surface all of it.


Table of Contents


Enabling the Database

Each agent carries an Agent.settings.dbEnabled flag (see Agent.swift). Toggling it on does three things:

  1. Tabs appear in the detail view. The agent management screen at AgentsView.swift gains five DB-backed tabs — Home, Schema, Data, Views, Activity — gated by DetailTab.allTabsForAgent(_:). Turning the flag off snaps back to the Configure tab so you never sit on a now-hidden DB tab with stale state.
  2. The model sees the db_* tools. SystemPromptComposer strips every db_* tool from the resolved tool list when dbEnabled == false; with it on, the agent gets the full surface listed in Tool Reference.
  3. The agent gets a fresh, empty SQLCipher database on first write. The file is lazy-opened on demand — no I/O happens until the agent calls a db_* tool.

The scheduler tools (schedule_next_run / cancel_next_run) are not gated on dbEnabled. Any agent can self-schedule; the database is a separate capability.


On-Disk Layout and Encryption

ArtifactPathLifecycle
Per-agent database~/.osaurus/agents/<uuid>/db.sqliteOne per agent, lazy-open
Cross-agent slots~/.osaurus/scheduler.sqliteSingle global file

Paths are resolved by OsaurusPaths.agentDatabaseFile(for:) and OsaurusPaths.schedulerDatabaseFile(). Both files are opened through EncryptedSQLiteOpener with the device-scoped key from StorageKeyManager — the same SQLCipher stack used by chat history, memory, and the rest of the app. See STORAGE.md for the key-management details.

The lazy-open lifecycle and per-agent singleton connection live in AgentDatabaseStore; the engine itself is AgentDatabase.


System Columns and the Soft-Delete Convention

Every table the agent creates via db_create_table is augmented with three reserved columns:

ColumnTypeSet by hostMeaning
_created_atTEXT ISOhostSet on insert. Never updated.
_updated_atTEXT ISOhostRefreshed on db_update / db_upsert writes.
_deleted_atTEXT ISOhostNULL for live rows; ISO timestamp once soft-deleted.

db_delete is a soft delete: it stamps _deleted_at rather than removing the row. db_restore clears the stamp. db_query filters out rows with a non-null _deleted_at by default — pass includeDeleted: true to see them.

The Data tab's Active / Deleted / All segmented control (in AgentDBTabViews.swift) maps directly to that flag: Active hides tombstones (the agent's default), Deleted only shows tombstones, All shows both with the soft-deleted rows dimmed.

There is no hard-delete tool. If you need to actually purge a row, do it from the host (the migrator or a developer console) — the model can't.


Tool Reference

All db_* tools live in Tools/Database/DatabaseTools.swift and delegate to LocalAgentBridge, which serialises mutations per-agent and stamps the _changelog. The scheduler tools live in Tools/Database/SchedulerTools.swift.

Schema management

ToolRole
db_schemaReturn the full schema snapshot (tables, columns, indexes, saved views).
db_create_tableCreate a new table with a stated purpose. Host adds the three _* system columns.
db_alter_tableAppend columns to an existing table.
db_migrateRun a multi-statement up/down migration the agent authored.

Row writes

ToolRole
db_insertInsert one or more rows.
db_upsertInsert-or-update keyed by an explicit conflict column set.
db_updateUpdate rows matching a typed where clause.
db_deleteSoft-delete — stamp _deleted_at. Restorable via db_restore.
db_restoreClear _deleted_at to bring a row back into the live set.

Reads and views

ToolRole
db_queryRun a read-only SELECT. Rows are capped; a truncated flag flips when the limit kicks in.
db_executeEscape-hatch arbitrary write. Restricted by host policy; reach for the typed tools first.
db_define_viewSave a parameterised SELECT under a name. Surfaces in the Views tab.
db_run_viewExecute a saved view with arguments.
db_list_viewsEnumerate saved views.
db_drop_viewDelete a saved view definition.

Scheduling

ToolRole
schedule_next_runUpsert the agent's single next-run slot. See contract.
cancel_next_runClear the slot without scheduling a new one.

The Changelog

Every mutation appends a row to a hidden _changelog table. Entries carry:

  • actor — one of agent, user, migration, system (AgentDatabaseActor in AgentDatabase.swift).
  • opinsert, update, soft_delete, restore, execute, … (AgentDatabaseOp).
  • table, row_id, at, plus tool-payload metadata so an audit can reconstruct what changed and why.

The Activity tab in the detail view surfaces these alongside agent_runs (the scheduler's run history) in a split pane. When a self-scheduled run wakes, BackgroundTaskManager seeds ChatExecutionContext.currentRunId / currentRunActor on the chat session so any db_* writes inside that run get stamped against the correct run row.


Storage Quota and Mutation Activity

Each agent declares its own quota via Agent.settings.limits.storageBytesMax. Two things consume it:

  • Hard limit. AgentDatabase.enforceStorageQuotaUnlocked() rejects writes once the on-disk file exceeds the limit. The error envelope tells the model what to do (typically delete or migrate older rows).
  • Soft warning. An edge-triggered .agentStorageWarn notification fires when usage crosses a configurable threshold (default ~80%). The UI surfaces this as a banner in the detail view and a system notification that deep-links into the Data tab.

The Data tab also shows a small per-agent in-flight mutation badge driven by AgentMutationActivity. The counter is bumped by LocalAgentBridge.serialized on every mutation entry/exit, so when an agent run is mid-write you can see the spinner instead of wondering whether the view is stale.


The Self-Scheduling Slot

Opt-in per agent. Self-scheduling is gated by Agent.settings.selfSchedulingEnabled (default off), surfaced as the Self-scheduling toggle under Configure → Features. When off, SystemPromptComposer strips the schedule_next_run / cancel_next_run / notify tools from the model's tool list, the Scheduling mode picker and the Next Run banner are hidden, and flipping the toggle off cancels any pending slot so an orphaned run can't fire after the user opted out. This flag is independent of the schedule mode — the mode only sets the bounds (below), while this flag decides whether the tools exist at all.

Schedules in Osaurus follow a deliberately minimal contract: one row per agent, in the agent_next_run table of ~/.osaurus/scheduler.sqlite (SchedulerDatabase.swift).

┌─────────────────────────┐     ┌─────────────────────────┐     ┌────────────────────┐
│ agent calls             │ ──▶ │ NextRunScheduler        │ ──▶ │ TaskDispatcher     │
│ schedule_next_run(...)  │     │   - clears slot         │     │   .selfSchedule    │
└─────────────────────────┘     │   - honors pause        │     └────────────────────┘
        ▲                       │   - applies on_miss     │              │
        │                       └─────────────────────────┘              ▼
        │                                                       BackgroundTaskManager
        └────────────────────── agent must re-call ◀─────── runs the chat, stamps
                              schedule_next_run for                agent_runs
                              another wake

Writes use SQLite ON CONFLICT upsert — last write wins. The runtime NextRunScheduler polls the table and, when a slot is due:

  1. Clears the row before dispatch (so a slow chat run can't double-fire).
  2. Checks any active agent_pause record.
  3. Applies the slot's on_miss policy if the slot fell behind while the app was asleep.

Because the slot is cleared on wake, wakeups are single-shot. If an agent wants to wake again it has to call schedule_next_run from inside the run. This is intentional — it's how the agent expresses "keep me alive" vs. "I'm done".

Every slot carries NextRunScheduledBy (.agent / .user / .system) so the audit trail and the Next Run banner can show "scheduled by you" vs. "scheduled by the agent".


schedule_next_run Contract

From SchedulerTools.swift. Pass either scheduled_at or in_seconds (not both).

FieldTypeRequiredMeaning
scheduled_atISO-8601 stringone ofAbsolute wake-up time.
in_secondsintegerone ofRelative offset from now.
instructionsstringyesThe "wake-up brief" the agent reads when it fires. Becomes the user turn.
context_viewsstring[]noSaved-view names to prefetch into the system prompt before the run starts.
prioritynormal | lownolow means "skip if the user is mid-conversation when due". Default normal.
on_missskip | run_once | run_catchupnoWhat to do when the wake-up time has already passed (e.g. laptop was asleep). Default skip.

cancel_next_run takes no fields beyond the implicit agent identity.

Bounds resolution

The requested time is clamped against the agent's schedule mode before it's persisted. Bounds are resolved by resolveAgentScheduleBoundsAgentManager → the mode preset. If the agent requests "10 days from now" but its mode caps maxHorizonSeconds at 24h, the slot is silently clamped to 24h and the tool result carries an AgentScheduleClampReason so the model knows why.


Schedule Modes

The modes from AgentScheduleMode in Agent.swift. Each one is a preset over AgentScheduleSettings; AgentScheduleSettings.defaults(for:) returns the values below. The mode sets the bounds for self-scheduling; whether the agent can self-schedule at all is the separate selfSchedulingEnabled toggle described above.

ModeMax horizonMin intervalDaily capQuiet hoursDays
Ambient7 days1 hour622:00–07:00All
Reactive24 hours5 minutes48NoneAll
Project30 days1 hour422:00–07:00All
Manual7 days15 minutes0NoneAll

Manual (dailyRunCap = 0) is a legacy off-state presetLocalAgentBridge.scheduleNextRun rejects agent-initiated slot writes under it. It is no longer offered in the picker (the selfSchedulingEnabled toggle owns on/off now), and is only reachable via legacy data; turning the Self-scheduling toggle on promotes a Manual mode to Ambient so a real preset is selected.

The mode picker lives in the Configure tab of the agent detail view (AgentsView.swift, scheduleSection) and is only shown when self-scheduling is enabled. It offers the three bound presets (Ambient / Reactive / Project) via selectableScheduleModes. Selecting a mode writes both settings.schedule.mode and the corresponding preset values via AgentScheduleSettings.defaults(for:) — so picking "Reactive" actually rewrites the cap, horizon, quiet-hours, etc., not just the label.


Pause Records

Pauses live in agent_pause (also in scheduler.sqlite). The row carries just two fields beyond the agent id:

  • paused_until — absolute resume timestamp.
  • reason — optional free-text, surfaced under the pause banner.

There is no paused_by column today. If you need to know whether a pause came from the UI or the agent, look at the reason text (UI paths and tool paths set distinguishable reasons in practice). DB-side audit attribution goes through _changelog's actor instead.

The Next Run banner's pause menu maps to these presets:

Menu itemBehaviour
1 hourpaused_until = now + 1h
4 hourspaused_until = now + 4h
Until tomorrowpaused_until = midnight at the start of the next day
Custom…Opens a sheet for an arbitrary date/time + reason.
Indefinitelypaused_until = .distantFuture

All five go through LocalAgentBridge.pauseAgent(_:until:reason:); Resume calls unpauseAgent. While a pause is active, NextRunScheduler refuses to dispatch even if a slot is due — the slot stays in the row and fires once the pause expires (subject to its own on_miss).


Detail-View Tour

All five DB tabs (and the scheduling chrome that sits above them) live in two files: AgentDBTabViews.swift and NextRunPanelView.swift.

Next Run banner

Three branches in NextRunPanelView:

  • scheduledRow — fires when there's a row in agent_next_run. Two-row layout: top row carries the relative time ("in 22h"), absolute timestamp, "by …" badge, the Pause menu, and a read-only Mode chip; bottom row carries Run now / Edit / Cancel actions.
  • pausedBanner — fires when an agent_pause row is active. Same two-row shape: pause icon + "Paused until X" + reason + Mode chip on top, Resume on the bottom.
  • idleBanner — fires when neither is set. Keeps the Pause menu and Mode chip visible so the user always has an entry point.

The Mode chip is read-only and tapping it posts an .agentDetailDeeplink notification with tab: "configure", jumping to the Configure tab's Scheduling section.

Configure → Scheduling

Gated behind the Self-scheduling toggle: the mode picker and the Next Run banner only render once selfSchedulingEnabled is on. When shown, the picker is three radio cards (scheduleModeCard in AgentsView.swift) for the Ambient / Reactive / Project presets, each showing the mode name, a one-line tagline, and the resolved preset summary so the user knows what they're switching to before they click.

Schema / Data / Views / Activity / Home

TabWhat it shows
SchemaRead-only catalogue of every table, its columns + types, and indexes. System tables grouped separately.
DataBrowse + edit rows. Table dropdown + Active / Deleted / All filter; per-row "Open" affordance; bulk soft-delete via row checkboxes; CSV export.
ViewsManage saved views. Split pane: list of view names on the left, definition + live preview on the right. Pin a view to show on Home.
ActivitySplit pane of agent_runs (left) and the selected run's _changelog entries (right). The audit log.
HomeDashboard of pinned views. The agent's "what should I look at right now?" tab.

A single NotificationCenter channel (Notification.Name.agentDetailDeeplink on AgentManager) routes navigation between the detail-view chrome and any view that wants to focus a specific entity. The userInfo shape:

KeyTypeMeaning
agentIdUUID (required)Which agent to focus.
tabStringDetailTab raw value (configure, schema, data, …).
tableRefString?Optional table name to pre-select on the Data / Schema tab.
viewRefString?Optional view name to pre-select on the Views tab.

Posters include the Mode chip in NextRunPanelView, the Data-tab "Open" buttons, the Schema-tab "Browse" buttons, and the system notifications fired by NotificationService. AgentsView is the sole subscriber — it flips selectedTab and threads the optional refs through to the focused tab's initialFocused… parameter.


  • Memory — Global, app-wide memory system. Orthogonal to Agent DB; an agent can use neither, either, or both.
  • Storage — Key management, SQLCipher encryption, and the full list of on-disk artifacts.
  • Agent Loop — The chat loop, the always-on todo / complete / clarify tools, and how db_* slots into the same ToolRegistry.
  • Features Overview — Complete feature inventory.