Everyone's talking about AI agents right now, but most tutorials stop at "call an LLM in a loop." If you actually want an agent that runs unattended — fetches data, thinks about it, writes content, and publishes it — you need a real backend, not just a prompt. This post walks through the architecture I use for exactly that: a scheduled agent that finds fresh data, drafts social posts with Claude, and publishes them, built entirely on Hono.js running on Cloudflare Workers.
I'll use "post finance news to LinkedIn/Reddit" as the running example, but the pattern generalizes to any "watch → think → act → publish" agent.
Why Hono.js for agent backends
Hono is a small, fast web framework that runs on Cloudflare Workers, Deno, Bun, and Node. For agent workloads specifically, three things make it a good fit:
- Native Cloudflare Cron Triggers — agents that run on a schedule don't need a separate job scheduler or a always-on server.
- Edge runtime, near-zero cold start — your agent wakes up, does its work, and disappears. You pay for execution, not idle uptime.
- Middleware model — auth, logging, and rate-limiting for your own agent's admin routes come for free.
The architecture
Cron Trigger (Hono on Cloudflare Workers)
│
├── 1. Fetch step → pull raw data from an external API
├── 2. Reasoning step → Claude API decides what's worth posting
├── 3. Generation step → Claude API drafts platform-specific copy
├── 4. Dedup check → Postgres/Neon, skip anything already posted
└── 5. Publish step → social API (or a unified posting provider)
Each step is a plain async function. No agent framework, no hidden state machine — just a pipeline you can read top to bottom and unit test.
Setting up the project
npm create hono@latest social-agent
cd social-agent
npm install
Pick the cloudflare-workers template when prompted. Then add what we need:
npm install @anthropic-ai/sdk drizzle-orm @neondatabase/serverless
Step 1: The cron trigger
In wrangler.toml, define when the agent wakes up:
[triggers]
crons = ["0 * * * 1-5"] # hourly, weekdays only
In your Hono app, handle the scheduled event separately from HTTP routes:
import { Hono } from 'hono'
const app = new Hono()
export default {
fetch: app.fetch,
async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext) {
ctx.waitUntil(runAgentCycle(env))
},
}
ctx.waitUntil is important — it tells the Worker runtime to keep the instance alive until your async work finishes, even though there's no HTTP response to wait on.
Step 2: Fetch fresh data
Keep this step dumb. It should return structured data, not decide anything.
interface RawEvent {
id: string
title: string
payload: Record<string, unknown>
}
async function fetchLatestEvents(env: Env): Promise<RawEvent[]> {
const res = await fetch('https://api.example.com/events?window=today', {
headers: { Authorization: `Bearer ${env.SOURCE_API_KEY}` },
})
if (!res.ok) throw new Error(`Source fetch failed: ${res.status}`)
const data = await res.json()
return data.events
}
Step 3: Let Claude decide what's worth posting
This is the part people skip and regret. Don't auto-draft a post for every single item — have the model triage first. It's cheaper, and it keeps your feed from looking like a bot dump.
import Anthropic from '@anthropic-ai/sdk'
async function triageEvents(events: RawEvent[], env: Env) {
const anthropic = new Anthropic({ apiKey: env.ANTHROPIC_API_KEY })
const message = await anthropic.messages.create({
model: 'claude-sonnet-4-6',
max_tokens: 1024,
system:
'You triage events for social media worthiness. Only flag items with a genuinely interesting angle — a surprise, a pattern, a number that stands out. Return strict JSON, no prose.',
messages: [
{
role: 'user',
content: JSON.stringify(events),
},
],
})
const text = message.content.find((c) => c.type === 'text')?.text ?? '[]'
return JSON.parse(text) as { id: string; angle: string }[]
}
Asking for "strict JSON, no prose" up front saves you a fragile regex-strip step later.
Step 4: Generate platform-specific copy
LinkedIn and Reddit have different norms — LinkedIn rewards a confident, analytical voice; Reddit punishes anything that reads like marketing copy. Generate both in one call, but prompt for the difference explicitly rather than reusing one draft everywhere.
async function draftPosts(item: RawEvent, angle: string, env: Env) {
const anthropic = new Anthropic({ apiKey: env.ANTHROPIC_API_KEY })
const message = await anthropic.messages.create({
model: 'claude-sonnet-4-6',
max_tokens: 800,
system: `Write two versions of a post about this event.
LinkedIn: 80-150 words, analytical tone, one soft mention of relevant context, no hashtag spam.
Reddit: framed as a discussion starter, no promotional language, ends with a genuine question.
Return JSON: { "linkedin": "...", "reddit": "..." }`,
messages: [
{ role: 'user', content: `Event: ${item.title}\nAngle: ${angle}\nData: ${JSON.stringify(item.payload)}` },
],
})
const text = message.content.find((c) => c.type === 'text')?.text ?? '{}'
return JSON.parse(text) as { linkedin: string; reddit: string }
}
Step 5: Dedup with Postgres
Nothing kills credibility faster than posting the same thing twice because a cron overlapped. Use Neon's serverless driver — it works over HTTP, which matters on Workers since you don't have a persistent TCP connection.
import { neon } from '@neondatabase/serverless'
async function alreadyPosted(env: Env, eventId: string) {
const sql = neon(env.DATABASE_URL)
const rows = await sql`SELECT 1 FROM posted_events WHERE event_id = ${eventId}`
return rows.length > 0
}
async function markPosted(env: Env, eventId: string, platform: string, postId: string) {
const sql = neon(env.DATABASE_URL)
await sql`
INSERT INTO posted_events (event_id, platform, post_id, posted_at)
VALUES (${eventId}, ${platform}, ${postId}, now())
`
}
Step 6: Publish
You have two real options here:
-
Native platform APIs. LinkedIn requires an approved Company Page and
w_member_socialscope; Reddit requires its own OAuth app and respects strict rate limits. Both are doable but slow to set up the first time. - A unified posting provider (e.g. Ayrshare) that abstracts multiple platforms behind one API. Much faster to ship an MVP with — worth it if you're validating the idea before investing in native integrations.
async function publish(platform: 'linkedin' | 'reddit', content: string, env: Env) {
const res = await fetch('https://api.ayrshare.com/api/post', {
method: 'POST',
headers: {
Authorization: `Bearer ${env.AYRSHARE_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ post: content, platforms: [platform] }),
})
if (!res.ok) throw new Error(`Publish failed on ${platform}: ${res.status}`)
return res.json()
}
Wiring it together
async function runAgentCycle(env: Env) {
const events = await fetchLatestEvents(env)
const flagged = await triageEvents(events, env)
for (const { id, angle } of flagged) {
if (await alreadyPosted(env, id)) continue
const source = events.find((e) => e.id === id)!
const { linkedin, reddit } = await draftPosts(source, angle, env)
const li = await publish('linkedin', linkedin, env)
await markPosted(env, id, 'linkedin', li.id)
// Reddit: hold for human review instead of auto-publishing — see note below
await queueForReview(env, id, reddit)
}
}
The guardrail that actually matters
Automating the fetch → draft pipeline is safe. Automating the publish step to Reddit is not, at least not at first. Most active subreddits have strict self-promotion rules, and an account that posts on a predictable schedule with promotional undertones gets flagged as a bot fast — sometimes shadowbanned entirely. Two things fix this:
- Human-in-the-loop for Reddit specifically. Queue the draft (Slack, email, a simple admin route in the same Hono app) and require a manual approve before it publishes.
- Keep the language descriptive, not advisory. For anything finance-adjacent, "revenue beat estimates by X%" is commentary; "you should buy this" edges into advice you don't want to be on the hook for.
LinkedIn is more forgiving of a consistent posting cadence, so it's the safer platform to fully automate first.
Where to go from here
This same skeleton — cron trigger, fetch, triage, generate, dedup, publish — works for far more than finance news. Swap the fetch step for GitHub releases, product reviews, conference CFPs, or your own product's usage metrics, and you have a different agent with the same reliability guarantees.
The part worth getting right early is the triage step. An agent that posts about everything is just noise with extra steps; an agent that only speaks up when there's a genuine angle is the one people actually follow.
If you're building something similar, I'd love to hear what you're automating — drop it in the comments.
Top comments (2)
The dedup check has a small race if two cron runs ever overlap. Both can pass alreadyPosted before either one marks the event, so you still get the double post. Inserting the row first with a unique constraint on event_id, then publishing only if the insert won, closes that gap.
This is great, thanks for sharing it