Most developers treat an LLM like a vending machine.
You put in a prompt. You get out a response. The machine goes back to sleep.
That works fine for chatbots. It breaks down completely the moment you want an AI that doesn't wait to be asked one that monitors your environment continuously, evaluates your state in the background, and decides on its own when to step in.
Building that kind of system requires something most AI tutorials never cover: a persistent background execution loop that runs independently of user input, collects telemetry silently, and triggers the model only when the data says it's time.
This is how I built that loop for JARVIS and what I learned doing it.
Why a Heartbeat Loop at All
The fundamental problem with passive AI is the direction of control.
In a standard implementation the flow is always:
User acts → System executes → Model responds → System sleeps
The AI is completely blind between interactions. It has no awareness of how long you've been working, whether your attention has shifted, or whether anything in your environment has changed. It only knows what you explicitly tell it.
A heartbeat loop inverts this. Instead of waiting for user input, a background process fires on a set interval independently of anything happening in the UI collects environmental data, evaluates it against thresholds, and decides whether intervention is warranted.
The flow becomes:
Heartbeat fires → Telemetry collected → Threshold evaluated → Intervention triggered (if needed)
The user never initiates. The system does.
The Architecture
Before writing any code, the architecture decision that mattered most was this: the heartbeat loop must never directly touch UI state.
This sounds obvious. It isn't. The temptation when building this is to have the background loop directly update state when it detects something trigger a voice response, animate an element, change a display. If you do that, you introduce shared mutable state between a background thread and a UI thread, and you will hit race conditions. The UI framework assumes state only changes in response to user actions. When a background process violates that assumption, you get freezes, infinite re-render loops, and failures that are almost impossible to trace because they're emergent they don't live in any single component.
The solution is strict producer/consumer isolation:
The heartbeat loop is a pure producer it reads environment data and dispatches events. It never writes to UI state directly.
The UI layer is a pure consumer it listens for events and manages its own state independently.
No shared mutable state between them. This single architectural decision eliminated an entire category of bugs.
The Telemetry Gateway Pattern
The other critical design decision was around API efficiency.
A naive implementation would query the LLM on every heartbeat cycle. That's wasteful, slow, and expensive. More importantly, it's unnecessary most heartbeat cycles don't need model involvement at all.
Instead, JARVIS uses what I call a Deterministic Gateway Pattern:
Step 1 — Continuous local ingestion
Every cycle, the heartbeat collects micro-metrics and writes them to an isolated local cache. No API calls. No model involvement. Just lightweight data collection:
typescriptinterface SystemTelemetry {
activeSessionMinutes: number;
lastIdleGapMinutes: number;
interfaceFocusState: "active" | "idle" | "away";
sessionStartTimestamp: number;
}
const collectTelemetry = async (): Promise => {
const now = Date.now();
const sessionMinutes = (now - sessionStartTimestamp) / 60000;
const idleGap = getLastIdleGapMinutes();
const focusState = getInterfaceFocusState();
return {
activeSessionMinutes: sessionMinutes,
lastIdleGapMinutes: idleGap,
interfaceFocusState: focusState,
sessionStartTimestamp
};
};
Step 2 — Local threshold evaluation
The collected metrics are evaluated entirely locally no model call, no network request. Just lightweight conditional logic:
typescriptconst FATIGUE_THRESHOLD_MINUTES = 120;
const BREAK_MINIMUM_MINUTES = 10;
const STRATEGIC_THRESHOLD_MINUTES = 60;
type AgentMode = "PASSIVE" | "STRATEGIC" | "PROTECTIVE";
const evaluateTelemetry = (telemetry: SystemTelemetry): AgentMode => {
const { activeSessionMinutes, lastIdleGapMinutes } = telemetry;
if (
activeSessionMinutes > FATIGUE_THRESHOLD_MINUTES &&
lastIdleGapMinutes < BREAK_MINIMUM_MINUTES
) {
return "PROTECTIVE";
}
if (activeSessionMinutes > STRATEGIC_THRESHOLD_MINUTES) {
return "STRATEGIC";
}
return "PASSIVE";
};
Step 3 — Autonomous inversion of control
Only when a threshold is crossed does the system involve the model. At that point, the orchestrator packages the full telemetry history, attaches a behavioral directive appropriate to the intervention type, and fires a targeted payload:
typescriptconst triggerIntervention = async (
mode: AgentMode,
telemetry: SystemTelemetry
) => {
if (mode === "PASSIVE") return;
const directive = mode === "PROTECTIVE"
? PROTECTIVE_DIRECTIVE
: STRATEGIC_DIRECTIVE;
const payload = {
telemetry,
directive,
sessionContext: await loadSessionContext()
};
interventionEventEmitter.emit("intervention", {
type: mode,
payload,
timestamp: Date.now()
});
};
The model is never called speculatively. It's called when the data says it's necessary.
The Full Heartbeat Loop
Putting it all together:
typescriptconst SESSION_START = Date.now();
const HEARTBEAT_INTERVAL_MS = 30000; // 30 seconds
const runHeartbeat = async (): Promise => {
try {
const telemetry = await collectTelemetry();
const mode = evaluateTelemetry(telemetry);
await triggerIntervention(mode, telemetry);
} catch (error) {
console.error("Heartbeat cycle failed:", error);
// Fail silently — never let a heartbeat failure surface to the user
}
};
// Start the loop
setInterval(runHeartbeat, HEARTBEAT_INTERVAL_MS);
Two things worth noting:
The try/catch is non-negotiable. A heartbeat loop runs indefinitely. One unhandled exception without a catch will crash the entire background process silently no error, no warning, just a loop that stopped firing. Always wrap the cycle body.
Fail silently toward the user. Heartbeat failures should be logged internally but never surfaced as UI errors. The user's workspace should be completely unaffected if the background loop has a bad cycle.
The UI Consumer Side
On the interface side, the component listens for intervention events and handles them entirely within its own state:
typescriptuseEffect(() => {
const handleIntervention = (event: InterventionEvent) => {
setInterventionQueue(prev => [...prev, event]);
};
interventionEventEmitter.on("intervention", handleIntervention);
return () => {
interventionEventEmitter.off("intervention", handleIntervention);
};
}, []);
The UI never asks the heartbeat what's happening. It just listens. The heartbeat never asks the UI what to display. It just dispatches.
Clean boundary. No shared state. No race conditions.
What This Architecture Gives You
Once the heartbeat loop is in place, the system fundamentally changes in character.
The AI is no longer a tool you invoke. It becomes an environment that runs continuously, maintains awareness of your state, and exercises its own judgment about when to act. The user's relationship with the system shifts it's no longer a conversation, it's more like a presence.
That shift requires one more thing beyond the technical implementation: the intervention logic has to be conservative. An AI that interrupts constantly is worse than one that never interrupts. The thresholds, the modes, the decision logic these need to be calibrated carefully. The heartbeat loop is infrastructure. What you do with it is design.
Key Takeaways-
A heartbeat loop requires strict producer/consumer isolation between the background thread and the UI layer. Any shared mutable state will cause race conditions.
Use a deterministic gateway pattern evaluate thresholds locally first, invoke the model only when necessary. Never query speculatively.
Always wrap the cycle body in try/catch. Silent failures in a background loop are the hardest bugs to diagnose.
The interval matters. 30 seconds is long enough to avoid performance impact, short enough to feel responsive. Tune it for your specific use case.
The loop is infrastructure. The intervention logic is where the real design work lives.
Myself Nidhish Akolkar I am a Computer Engineering student and AI Systems Engineer based in Pune, India. I build autonomous multi-agent AI infrastructure and run a funded institutional AI & ML laboratory.
GitHub: github.com/nidhishakolkar01-lgtm
LinkedIn: linkedin.com/in/nidhish-a-akolkar-30a33238b
Top comments (0)