Autonomy Engine — Event-Driven, Intention-Gated Autonomy
⚠️ Reconciled 2026-09-17 against the shipped daemon. The “always-on background service” this doc originally proposed is the daemon (src/gaia/daemon/), which now exists: one process, oneDaemonClock(daemon/scheduler/clock.py), sidecars per agent. Don’t build a second one. What’s below is corrected to match what’s actually there today, not what was planned in April — see §3 for the current-state audit and §14 for what changed. The load-bearing finding of this update: the scheduler ships with zero executors.DaemonClockcan track a due job, but nothing fires when one comes due (server.py:271’s own docstring says so) — every capability in this doc downstream of “a job runs” is blocked on that one piece of wiring, not on any of the fancier ideas (event hooks, self-scheduling, connector triggers) layered on top.
Date: 2026-04-01 (original) · updated 2026-09-17 Status: Partially implemented. Foundation shipped (daemon,DaemonClock,KIND_ONE_SHOT/KIND_RECURRINGjob kinds,EmailTriageAgent’s trust-gated heartbeat as a working precedent). Everything else in this doc — a registration surface, an executor, self-scheduling, event/connector triggers, the tray app — is still 0%. Milestone: v0.23.0 — Autonomous Agent Infrastructure Prerequisites: Memory (MemoryStore, MemoryMixin) — shipped. Daemon +DaemonClock— shipped. Stall-time / temporal grounding (#3969) — proposed, complementary: it’s the cheapest signal for “has the user gone quiet long enough to consider a follow-up.” Related plans: the daemon per its own v2 crosswalk note before building anything else here; it already solves “decide when to act, not just on a fixed schedule” for one agent, and the general engine should generalize its mechanism rather than invent a second one (§9).
- Security Model — Dangerous Mode design (still “Planning”); itself flagged partially superseded under the sidecar architecture
- Messaging Integrations — Messaging adapters depend on this service
1. What We’re Building
The Autonomy Engine turns the agent from a reactive chat tool into a proactive one: something other than a typed message can start a turn, and the agent decides for itself whether that turn is worth having — not every trigger deserves an LLM call, and not every LLM call deserves interrupting the user. Four trigger sources, not one:- Schedule fires — cron/interval jobs, the daemon’s own clock (§4).
- Tool-result triggers — a long-running or background tool finishes and the result itself is the reason to act (§6).
- Connector events — a connected service (Gmail, GitHub, an MCP server) has something new (§6).
- System signals — file changes, login, network state, other local conditions (§6).
2. GitHub Issue Cross-References
3. Current-State Audit (2026-09-17)
Before adding anything, here’s what’s actually there, so this doc stops describing a service that no longer needs to be built and starts describing the gaps in the one that exists.3.1 The daemon is the always-on process — this part is done
src/gaia/daemon/ is a single loopback-bound FastAPI process, self-started and tracked
via instance.json (daemon/server.py:208-318). It already has the four subsystems this
doc originally invented a diagram for:
- Custody (
daemon/custody/) — reverse-contract API for a running sidecar to call back into the daemon (memory scope, RAG queries). - Broker (
daemon/broker.py) — arbitrates Lemonade’s single model slot across processes by priority (interactive beats background). - Scheduler (
daemon/scheduler/) — one persistent, SQLite-backed clock. This is the “one clock, not four” outcome this doc’s original banner asked for, and it shipped. - Sidecars (
daemon/sidecars/) — spawns, health-checks, and relays to per-agent processes.
3.2 The scheduler clock exists; nothing is wired to fire
KIND_ONE_SHOT/KIND_RECURRING are real job kinds (daemon/scheduler/models.py:21-24),
and store.register_job() (store.py:98-132) genuinely persists a job with either
fire_at (epoch, one-shot) or interval_seconds (recurring) — no cron-expression
support today, unlike the CLI’s gaia schedule (which uses croniter in a separate,
unmigrated store).
Two gaps block everything downstream:
- No registration surface.
register_job()has exactly one caller in the whole codebase: the legacy-scheduler migration path (daemon/scheduler/migration.py:63). There is no REST endpoint and no agent tool that lets anything — a human, the TUI, or the agent itself — register a new job today. - No executors.
_build_clock()(server.py:131-143) is wired with zero executors in production; the docstring says so directly.DaemonClock.fire_due()(clock.py:130-140) marks any due jobfailed: no executor registered for job kind. A job can be due and still do nothing.
/query”)
and expose one way to register a job. Everything else in this doc is downstream of that.
3.3 A sidecar invocation is a full agent turn — no lighter path exists
When something does eventually call a sidecar for a scheduled job, the shape isdaemon/agent_query.py:run_query() → the relay → /v1/<agent>/query, the same canonical
SSE-streamed, full LLM/tool loop as an interactive call
(hub/agents/email/python/gaia_agent_email/agent.py:1214). There is no “just run a
deterministic check” fast path in the daemon itself — the cheap-first tiering this doc
wants has to be built either as a pre-check before the daemon ever calls the sidecar, or
inside the agent’s own on_heartbeat-style entry point (which is exactly how email does
it — see §9).
3.4 Governance already gates tool execution, generically
GovernedAgentMixin (src/gaia/governance/mixin.py:67-174) intercepts
Agent._execute_tool and drives ALLOW/BLOCK/REVIEW through a pluggable adapter, with
checkpoint confirmation that fails closed if nothing is attached to review it
(mixin.py:330-346). It applies the same way whether the turn was started by a human
message or (once wired) a scheduler fire — good, reusable, no changes needed for
autonomy specifically. What it does not have: any rate-limiting or
frequency-of-autonomous-action knob. That’s still a gap (§14).
3.5 Connectors are pull-only — there is no push trigger today
No webhook receiver, subscription, or event-push mechanism exists for Gmail, GitHub, or MCP-server connectors (src/gaia/connectors/). The only event fan-out that exists
(connectors/activation_watcher.py, connectors/events.py) is a 1-second poll of a
local JSON ledger for UI-facing connector-activation state, not external data events.
An agent has to call a tool to check for new mail or a new PR — nothing notifies it.
“Connector has an event” as a trigger source (§6) is genuinely new work, not a rename of
something that exists.
3.6 The activity feed and “new session, never interrupts” concept don’t exist in code
Noorigin: "autonomy" session metadata, no activity-feed table, no timeline UI. The one
real, narrower analog: src/gaia/ui/agent_loop.py:457-459 saves autonomous ticks as
session messages tagged role="autonomous", hidden from the UI by default. Sections 7,
10, and 11 below are still aspirational design, not a correction — just flagging that
“session management” (§10) is 0% built, not partially.
4. Heartbeat Scheduler
The original design here is still the right shape; it just needs to be built as an executor on the daemon’s existing clock, not a second scheduler. Config format, cheap-first tiers, and built-in checks are unchanged from the original plan (kept below for reference) — what changed is that they now have a concrete home:DaemonClock +
one or more registered executors, not a bespoke YAML-driven loop in a separate process.
Configuration
Built-in Checks
Phase 1 ships with deterministic (zero-token) checks:check_git_changes (git status/log
on watched repos), check_new_files (directory diff against last snapshot),
check_disk_space (partition usage), check_watched_dirs (file stat deltas). MCP-based
checks (check_calendar, check_email) are also zero-cost. LLM-backed checks
(summarize_if_needed, daily_summary, generate_daily_brief) only run when prior checks
find changes or on explicit schedule.
Four hub skills already implement the reporting half of this pattern in prompt form
— price-watch and source-watch (hub/skills/{price-watch,source-watch}/SKILL.md)
recall prior state, fetch, compare, and only report or remember if something changed.
They do not have a deterministic zero-token pre-check yet: every run still costs a
full LLM pass just to conclude “nothing changed,” because none of them can self-schedule
today (each explicitly states --skill is rejected by gaia schedule at creation time).
Wiring an executor (§3.2) that runs a cheap check before invoking these skills at all is
a concrete, scoped near-term win.
Cheap-First Execution Pattern
- Tier 0 — Deterministic (zero cost): Run checks. If nothing found, log “all clear” and stop. No LLM is loaded.
- Tier 1 — Triage (Qwen3-0.6B, ~600MB, 50-200 tokens): Ask the cheap model “Is this worth notifying the user? YES/NO with a one-line reason.” If NO, log and stop.
- Tier 2 — Action (Qwen3.5-35B, ~4GB MoE/3B active, 500-5000 tokens): Create a new chat session with full context. Agent uses tools, generates summaries, drafts responses. Notify the user via desktop notification and activity feed.
5. Autonomous Loop (Think-Act-Schedule), Including Self-Scheduling From Conversation State
The autonomous loop lets an agent plan multi-step work across time — and lets it decide, on its own, that a follow-up is warranted, not just execute a follow-up a human pre-configured.Two ways a follow-up gets created
- User-requested (unchanged from the original doc): “Monitor this PR and notify me
when CI passes,” “remind me in 2 hours.” The agent calls
schedule_followupas part of executing an explicit ask. - Agent-initiated, from conversation state (new in this update, per the “GAIA should
manage its own cron job based on conversation/engagement” ask): the agent notices it’s
waiting on something — a tool call still running, a confirmation the user hasn’t
answered, a question it asked that got no reply — and schedules its own check-back,
without being asked to. This is the same shape as case 1 mechanically
(
schedule_followup), but the trigger is the agent’s own judgment instead of an explicit instruction.
Cycle: THINK -> ACT -> SCHEDULE -> COMPLETE
- Think: Analyze trigger and findings (or, for case 2, notice the stall), plan next action.
- Act: Execute tools, write files, call APIs.
- Schedule: Optionally create a follow-up task via
schedule_followuptool. - Complete: Log result, notify if needed, yield.
Self-Scheduling Constraints
- Maximum 24-hour delay (longer recurrence belongs in
heartbeat.yaml). - Maximum 10 pending follow-ups at any time (prevents runaway scheduling).
- Each follow-up runs at most once (one-shot, not recurring) — an agent that wants to
keep checking back schedules its next one-shot when the previous one completes, it
doesn’t create a recurring job for itself. Recurring jobs stay human-configured
(
heartbeat.yaml) so a misjudging agent can’t wind up a runaway clock. - Follow-ups inherit the dangerous-mode setting of the creating session.
- A follow-up must justify itself before it fires, not just because it’s due — it still goes through Tier 0/Tier 1 cheap-first checks (§4) before spending a full turn. “I scheduled a check-in” is not a bypass of “is this worth acting on.”
KIND_ONE_SHOT in the scheduler’s own SQLite store (daemon/scheduler/models.py),
not a separate ~/.gaia/autonomy/tasks/pending/ JSON directory — the original doc
predates the daemon’s own persistence and should defer to it rather than duplicate it.
6. Triggers: Schedule, Tool Result, Connector Event, System Signal
Four trigger sources feed the same Tier-0/Tier-1/Tier-2 pipeline (§4). None of them should be allowed to skip Tier 0/1 just because they’re “an event, not a poll” — an event firing is not the same claim as “this is worth an LLM call.”Schedule fires
Cron/interval jobs via the daemon clock (§3.2, §4). The most mechanically simple trigger and the one blocking everything else on wiring an executor.Tool-result triggers (new)
A tool call that runs long or asynchronously (a background shell command, an install, a long-running search) completing is itself a reason to resume a turn or start a new one — “the build finished” is exactly the kind of thing a heartbeat-style deterministic check could otherwise only discover by polling. No existing mechanism reports this today; it needs a way for a tool’s completion to enqueue a (Tier-0-gated) wake, rather than the agent having to poll “is it done yet” on its own schedule.Connector events (new)
“A connector has something new” — new email, new PR comment, a calendar event about to start. Connectors are pull-only today (§3.5) — this is genuinely new infrastructure, not a rename. Two paths, in order of how much new surface they require:- Cheaper, ships first: a scheduled job (§4) that calls the connector’s existing pull API on an interval and only escalates past Tier 0 if something’s actually new — reuses everything, adds no push infrastructure, at the cost of latency (checks on an interval, not the instant something happens).
- More capable, more work: an actual webhook/subscription receiver per connector (Gmail push notifications, GitHub webhooks), which needs a public or tunneled endpoint, payload validation, and per-connector wiring. Worth it for latency-sensitive cases; should not be the default answer for every connector.
System signal triggers
File system watchers (watchdog), and platform-native triggers (login, network
connected, USB attach, screen unlock) via Task Scheduler/WMI, systemd/D-Bus, or
launchd/IOKit depending on platform. Unchanged from the original plan; still 0% built.
7. System Tray App
Unchanged from the original plan. The tray app is the user-facing control surface for background processes — status indicator, quick actions, running-services list, settings — built on Electron’sTray/Menu APIs. Given §3.1, this is a UI onto the existing
daemon, not a new process to manage.
8. Desktop Notifications
Unchanged from the original plan: ElectronNotification API with plyer/win10toast
fallback in headless mode, suppressed (not skipped — tasks still run and log) during
quiet hours.
9. Intention-Gating: Generalize the Trust Pattern Email Autonomy Already Proved
This is the section that answers “how do we make sure GAIA only engages with intention, not just because a timer fired.” Don’t design this from scratch — it’s already shipped once, forEmailTriageAgent:
on_heartbeatruns a zero-token delta-check first (has anything actually changed since last time) — the real Tier-0 in production, today, not a plan.- A
TrustPolicydecidesauto | draft | suggest | confirmper action and per scope, not a single global switch — email can auto-file a newsletter but still always confirm before sending, forwarding, deleting, or RSVPing (an inviolable confirm-floor, not configurable away). - A
TrustLedgerof past correction/outcome history lets trust earn up over time for a given action/scope, rather than being a static permission the user sets once — the agent’s autonomy increases as it demonstrates it deserves it, and a correction can pull it back down. - This rides the daemon’s single
DaemonClockalready — no bespoke scheduler.
TrustPolicy/TrustLedger out of the
email package into something any agent’s heartbeat/event handler can use, with the same
inviolable-floor concept (some actions are never auto-approved regardless of earned
trust — the general engine needs its own list of those, not email’s). This turns “engage
with intention” from a principle into a mechanism every autonomous trigger in §6 can
call before acting, instead of five different ad hoc “should I bother the user” checks
per trigger type.
Safe Mode / Dangerous Mode
Unchanged in spirit from the original doc and fromsecurity-model.mdx §10 (still
“Planning,” not shipped): safe mode requires confirmation for any write/shell/message
action; dangerous mode is opt-in, per-task, session-scoped, and prominently logged. What’s
new here: this should compose with the generalized trust policy above rather than being a
separate on/off switch — dangerous mode is “skip the confirm step trust would otherwise
still require,” not a separate axis.
10. Session Management
Design unchanged from the original doc (new session per Tier-2 escalation, never inject into an active conversation, session-origin metadata, activity feed). Per §3.6, none of this exists in code yet — the only real analog isrole="autonomous" hidden session
messages in src/gaia/ui/agent_loop.py:457-459. Treat this section as fully unbuilt, not
partially.
11. Cross-Platform Background Service
Unchanged —PlatformService abstraction (install/uninstall/start/stop/
is_running) over Registry/Task Scheduler (Windows), systemd user service (Linux),
launchd (macOS). This describes making the existing daemon auto-start at boot/login,
not standing up a second service.
12. CLI Commands
gaia autonomy and gaia schedule (the existing, separate, cron-capable CLI surface)
should be reconciled into one command instead of two competing ones.
13. Key Design Decisions
- One process, not a second one. Confirmed correct and shipped: the daemon is the always-on process; nothing here should stand up another.
- Cheap-first, not LLM-first. Still the right call, still unbuilt at the infrastructure level (§3.3) — proven at the application level by email autonomy (§9). Generalize that, don’t reinvent it per trigger type.
- New sessions, not injection. Unchanged; unbuilt (§3.6, §10).
- Safe by default, trust earned per action/scope, not granted globally. Updated from the original “safe by default” decision to match what email autonomy actually proved: a single dangerous-mode toggle is cruder than a per-action trust ledger with an inviolable floor. Generalize the latter.
- YAML config for what’s schedule-shaped; the daemon’s own store for what’s job-shaped. Heartbeat definitions (what to check, how often, in human-editable form) still belong in YAML. Job state (is it due, has it fired, what’s its next fire time) belongs in the daemon scheduler’s existing SQLite store — don’t build a second persistence layer for that half.
- The daemon’s existing HTTP surface is the IPC mechanism. Unchanged; no new transport needed.
14. Open Questions
Original five questions (model preloading, multi-agent heartbeats, memory integration, messaging-adapter delivery, MCP rate limiting) are all still open and unchanged from the April draft. Added from this update’s audit:-
Who builds the first executor, and for which job kind? This is the actual
unblocking task (§3.2) — everything else in this doc is downstream of it. Recommend
scoping it as its own issue, narrow: one
KIND_RECURRINGexecutor that POSTs a fixed prompt to one named agent, plus one registration path (even CLI-only at first) to create such a job. Prove the wiring before generalizing to heartbeat YAML, self- scheduling, or connector triggers. -
Cron vs. interval in the daemon clock.
gaia schedule(the CLI) supports cron viacroniter; the daemon clock only supportsfire_at/interval_seconds. Does the daemon clock gain cron support, or does everything above it (heartbeat YAML,/loopin the TUI — #3960) translate cron into intervals before calling the daemon? -
Where does the generalized trust policy (§9) live? As a new mixin next to
GovernedAgentMixin, or folded into governance itself? It needs to be usable by any agent’s heartbeat handler, not just email’s. -
Rate limiting for autonomous triggers broadly, not just external MCP calls as the
original question framed it — a misbehaving tool-result or connector-event trigger
could fire far more often than a cron schedule ever would. Governance today
(
governance/config.py) has no frequency knobs at all (§3.4). - Connector events: polling-based (cheap, reuses everything) or push-based (webhook/ subscription, lower latency, real new infrastructure)? §6 recommends shipping the polling path first and treating push as a per-connector upgrade, not a prerequisite.