Skip to content

Recurring work — schedules, heartbeats, data feeds

The platform exposes three different primitives for recurring work, and partners get them confused. They look similar from the outside — “run something on a clock” — but they have different ergonomics, different cost profiles, and different runtime guarantees.

This guide tells you which one to reach for.

  1. You want your container to wake up and run plain Python/JS code on a cron, no LLM, no agent context, no pre-filter → use an App Schedule.
  2. You want an agent to wake up periodically, check something cheaply, and only burn LLM tokens when there’s something new to reason about → use a Heartbeat.
  3. You want to keep a snapshot of skill data in a queryable records table, with no LLM and no agent → use a Data Feed with on_change: write_records.

Heartbeats and data feeds share the same underlying table (linkworld_app_data_feeds); app schedules don’t materialize as rows at all. That’s why an inbox_sweep schedule won’t appear on /user/data-feeds — different beast, different home.

App ScheduleHeartbeatData Feed (write_records)
Manifest sectionlifecycle.schedules[]agents[].heartbeats[]created at runtime
Persisted in(none — manifest at fire-time)linkworld_app_data_feeds (on_change='invoke_agent')linkworld_app_data_feeds (on_change='write_records')
What firesschedule:<name> event to the app containerLLM call to the agent, after a cheap pre-filter says “something changed”Skill tool call, output written to linkworld_app_records
Container code runs?✅ — your @app.on_schedule(name) handler❌ (the agent runs, not your container code)❌ — pure-data path, no app code
Pre-filter (zero-LLM check)✅ — skill+tool query gates the agent invoke
Driver loop_app_schedule_scanner (60s tick, leader-elected)TenantScheduler source 4TenantScheduler source 4
Visible in UInowhere today (see below)/user/data-feeds/user/data-feeds
Audit-log entryNone for the dispatch itself; tool calls inside the handler audit normallyPre-filter call audited; agent’s tool calls auditedSkill-tool call audited

Your container wakes up on a cron. You run whatever code you want inside the handler. The platform doesn’t pre-filter, doesn’t check “has anything changed”, doesn’t reason about it — it just delivers schedule:<name> to your container and your handler runs.

linkworld.app.yaml
lifecycle:
schedules:
- name: inbox_sweep
cron: "*/5 * * * *"
- name: daily_digest
cron: "0 18 * * *"
main.py
@app.on_schedule("inbox_sweep")
async def sweep(ctx):
mails = await ctx.tools.call("email_search", received_after="-30m")
for m in mails["emails"]:
await classify_and_route(ctx, m)

Use when:

  • Every tick has potential work — you can’t pre-filter cheaply (“any new mail” requires fetching to know)
  • The work is procedural and stateful (loops, branches, multiple tool calls)
  • You want one handler that owns the whole flow vs. delegating to an agent

Don’t use when:

  • Most ticks have nothing to do (waste of container wakeup) — use a Heartbeat instead, the pre-filter saves cycles
  • You want declarative “watch X, write Y” semantics — use a Data Feed

Delivery: at-most-once, minute-precision, leader-gated. If a worker rotates over the tick boundary, that minute’s run is lost. The next tick comes regardless. Don’t write code that depends on “every tick fires exactly once”.

Cost: roughly 1 HTTP call to the container per tick + whatever your handler does. Tokens only when your handler chooses to call an LLM.

Your agent wakes up on a schedule. The platform first runs a cheap pre-filter — a zero-LLM skill call (typically a count or a recent-records query) — and only invokes the agent’s LLM if the pre-filter says something changed. Most ticks are free.

agents:
- id: marketing_lead
heartbeats:
- slug: morning_brief
skill: graph_email
tool: email_search
params:
received_after: "-1h"
schedule: { type: cron, expr: "0 8 * * *" }
on_change: invoke_agent

The platform materializes that as a row in linkworld_app_data_feeds with on_change='invoke_agent'. The TenantScheduler picks it up every 15s and decides per row whether the pre-filter has new content. If it does, the row’s target_agent_slug gets invoked with the records as context.

Use when:

  • The agent needs to react to changes in some data source (“any new GitHub PR comments since I last looked?”)
  • Most ticks should be no-ops (cheap to check, expensive to act)
  • You want the agent’s reasoning + tool-use loop in the response path, not a hard-coded handler

Don’t use when:

  • You want pure data sync without an agent — use a Data Feed with on_change='write_records'
  • You want unconditional code execution every tick — use an App Schedule

Delivery: at-most-once per due-window, with built-in cooldown + auto-disable after 5 consecutive failures.

Cost: pre-filter call every tick (~free if it’s a DB query or a cheap API). LLM tokens only when pre-filter trips, empirically ~20% of ticks for a well-designed heartbeat.

See the heartbeats reference for the full materialization + escalation flow.

A row in linkworld_app_data_feeds that, when next_run is due, calls a skill+tool, takes the result, and writes it into linkworld_app_records keyed by record_type + dedup_key. No agent, no LLM. Pure data sync.

Created by the Vision Loop’s PLAN phase, by manual /user/data-feeds UI input, or implicitly via heartbeat manifest declarations.

Use when:

  • You want to maintain a snapshot of external data (price lists, calendar feeds, status pages) for later querying via read_app_records
  • The agent should look at a stable record store, not poll the external system live
  • Pure read → write semantics — no decision making

Don’t use when:

  • You need the agent to react in the moment a change happens → Heartbeat
  • You need procedural code → App Schedule

Delivery: at-least-once with dedup keys. The records table de- duplicates so a re-fired sync overwrites instead of duplicating.

Cost: one skill call per tick. Zero tokens. Records-table storage cost.

”Process new emails every 5 minutes” → App Schedule

Section titled “”Process new emails every 5 minutes” → App Schedule”

The handler must classify + route every mail. Pre-filter (“any new mail?”) doesn’t help because every new mail is potential work, and the routing decision is procedural (different categories trigger different code paths). inbox-manager does this.

”Notify me about Slack mentions” → Heartbeat

Section titled “”Notify me about Slack mentions” → Heartbeat”

A pre-filter “count of new mentions since last tick” is cheap. Only invoke the agent when count > 0. Agent reads the new mentions, decides priority, optionally drafts replies.

”Keep current Hetzner cloud prices in app records” → Data Feed

Section titled “”Keep current Hetzner cloud prices in app records” → Data Feed”

hetzner_list_prices skill, on_change: write_records, record_type: hetzner_pricing, dedup_key: server_type+region. Hourly cron. Agent later does read_app_records('hetzner_pricing', where=…) for instant lookup, no live API call.

PrimitivePage
App Schedules(none today — see Workspace Control roadmap)
Heartbeats/user/data-feeds (filtered to invoke_agent)
Data Feeds (write_records)/user/data-feeds

App schedules being invisible in the UI is a known gap — they live in the manifest (frozen at the install’s pinned version) and don’t project into a queryable table. A future Workspace Control “App Schedules” view will read manifest schedules across all installs. Until then: check the audit log for tool calls (every tick that ran will leave traces) and your container’s stdout.

  • Treating an App Schedule like a Heartbeat. Don’t waste tokens classifying every tick if a pre-filter would skip 80% of them.
  • Treating a Heartbeat like an App Schedule. Don’t put procedural multi-step logic in a heartbeat agent prompt — the agent’s context window is the wrong place for “if X then call Y else if Z…”. Move that to an app-schedule-driven handler.
  • Forgetting that App Schedules don’t materialize. When you publish a new version with a different cron, the next leader tick reads the new manifest and behaves accordingly — no migration step. But you also can’t query “what’s scheduled” — it’s always whatever the current published manifest says.
  • Mixing the three in one app without explaining it. If your app has two schedules, three heartbeats, and four data feeds, put a one-paragraph “scheduling overview” in your README. Future maintainers will thank you.