Cron with Storage — Agentuity Documentation

Cron with Storage

Cache scheduled task results in KV for later retrieval

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.

typescriptsrc/api/hn/route.ts
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:

tsxsrc/web/App.tsx
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/stories

To 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 Also