Refresh data on a schedule, cache it in KV, then serve the last result from a regular GET route. This keeps reads fast and avoids refetching external APIs for every request.
The Pattern
Fetch external data on a schedule and store it in KV. A separate GET endpoint retrieves the cached results.
import { Hono } from 'hono';
import type { Env } from '@agentuity/runtime';
import { cron } from '@agentuity/runtime';
import { s } from '@agentuity/schema';
const router = new Hono<Env>();
const TopStoryIdsSchema = s.array(s.number());
const StorySchema = s.object({
id: s.number(),
title: s.string(),
score: s.number(),
by: s.string(),
url: s.optional(s.string()),
});
type Story = s.infer<typeof StorySchema>;
const CachedStoriesSchema = s.object({
stories: s.array(StorySchema),
fetchedAt: s.string(),
});
type CachedStories = s.infer<typeof CachedStoriesSchema>;
// Runs every hour and caches top HN stories
router.post('/digest', cron('0 * * * *', { auth: true }, async (c) => {
c.var.logger.info('Fetching HN stories');
const idsRes = await fetch('https://hacker-news.firebaseio.com/v0/topstories.json');
const ids = TopStoryIdsSchema.parse(await idsRes.json());
const stories = await Promise.all(
ids.slice(0, 5).map(async (id) => {
const res = await fetch(`https://hacker-news.firebaseio.com/v0/item/${id}.json`);
const story = StorySchema.parse(await res.json());
return {
id: story.id,
title: story.title,
score: story.score,
by: story.by,
url: story.url || `https://news.ycombinator.com/item?id=${story.id}`,
};
})
);
await c.var.kv.set('cache', 'hn-stories', {
stories,
fetchedAt: new Date().toISOString(),
}, { ttl: 86400 });
c.var.logger.info('Stories cached', { count: stories.length });
return c.json({ success: true, count: stories.length });
}));
router.get('/stories', async (c) => {
const result = await c.var.kv.get<CachedStories>('cache', 'hn-stories');
if (!result.exists) {
return c.json({ stories: [], fetchedAt: null });
}
return c.json(result.data);
});
export default router;Frontend
A simple interface can read the cached result without calling the protected cron endpoint:
import { useState, useEffect } from 'react';
interface Story {
id: number;
title: string;
score: number;
by: string;
url: string;
}
export function App() {
const [stories, setStories] = useState<Story[]>([]);
const [fetchedAt, setFetchedAt] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const loadStories = async () => {
const res = await fetch('/api/hn/stories');
const data = await res.json();
setStories(data.stories || []);
setFetchedAt(data.fetchedAt);
};
const refreshStories = async () => {
setIsLoading(true);
await loadStories();
setIsLoading(false);
};
useEffect(() => {
loadStories();
}, []);
return (
<div style={{ padding: '2rem', maxWidth: '600px' }}>
<h1>HN Top Stories</h1>
<div style={{ marginBottom: '1rem' }}>
<button onClick={refreshStories} disabled={isLoading}>
{isLoading ? 'Refreshing...' : 'Refresh'}
</button>
{fetchedAt && (
<span style={{ marginLeft: '1rem', color: '#666' }}>
Last updated: {new Date(fetchedAt).toLocaleString()}
</span>
)}
</div>
{stories.length === 0 ? (
<p>No stories cached yet.</p>
) : (
<ul style={{ listStyle: 'none', padding: 0 }}>
{stories.map((story) => (
<li key={story.id} style={{ marginBottom: '1rem' }}>
<a href={story.url} target="_blank" rel="noopener noreferrer">
{story.title}
</a>
<div style={{ fontSize: '0.85rem', color: '#666' }}>
{story.score} points by {story.by}
</div>
</li>
))}
</ul>
)}
</div>
);
}Testing Locally
Cron schedules only trigger in deployed environments. Locally, you can read cached data with the GET route:
curl http://localhost:3500/api/hn/storiesTo exercise the cron handler locally, temporarily set { auth: false } or send a signed request with X-Agentuity-Cron-Signature and X-Agentuity-Cron-Timestamp. Keep { auth: true } for deployed cron routes.
Key Points
cron()middleware wraps POST handlers with a schedule expression- KV with TTL automatically expires stale data (24 hours in this example)
- Separate GET endpoint lets clients retrieve cached results without cron auth
- Local testing requires a manual POST because schedules only run when deployed
See the Scheduled Digest example for a Hacker News + GitHub digest using cron, KV storage, and durable streams.
See Also
- Cron Routes for schedule expressions and patterns
- Key-Value Storage for KV operations and TTL options