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.
TL;DR — the decision tree
Section titled “TL;DR — the decision tree”- 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.
- 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.
- 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.
Comparison at a glance
Section titled “Comparison at a glance”| App Schedule | Heartbeat | Data Feed (write_records) | |
|---|---|---|---|
| Manifest section | lifecycle.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 fires | schedule:<name> event to the app container | LLM 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 4 | TenantScheduler source 4 |
| Visible in UI | nowhere today (see below) | /user/data-feeds | /user/data-feeds |
| Audit-log entry | None for the dispatch itself; tool calls inside the handler audit normally | Pre-filter call audited; agent’s tool calls audited | Skill-tool call audited |
App Schedule
Section titled “App Schedule”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.
lifecycle: schedules: - name: inbox_sweep cron: "*/5 * * * *" - name: daily_digest cron: "0 18 * * *"@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.
Heartbeat
Section titled “Heartbeat”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_agentThe 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.
Data Feed
Section titled “Data Feed”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.
Worked examples
Section titled “Worked examples””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.
Where do I see them in the UI?
Section titled “Where do I see them in the UI?”| Primitive | Page |
|---|---|
| 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.
Common pitfalls
Section titled “Common pitfalls”- 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.