DEV Community

Cover image for Building AI Agents for Social Media with TypeScript and Hono.js
Mayuresh Smita Suresh
Mayuresh Smita Suresh Subscriber

Posted on

Building AI Agents for Social Media with TypeScript and Hono.js

Real database upserts fix cron race conditions

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Pick the cloudflare-workers template when prompted. Then add what we need:

npm install @anthropic-ai/sdk drizzle-orm @neondatabase/serverless
Enter fullscreen mode Exit fullscreen mode

Step 1: The cron trigger

In wrangler.toml, define when the agent wakes up:

[triggers]
crons = ["0 * * * 1-5"] # hourly, weekdays only
Enter fullscreen mode Exit fullscreen mode

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))
  },
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 }[]
}
Enter fullscreen mode Exit fullscreen mode

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 }
}
Enter fullscreen mode Exit fullscreen mode

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())
  `
}
Enter fullscreen mode Exit fullscreen mode

Step 6: Publish

You have two real options here:

  1. Native platform APIs. LinkedIn requires an approved Company Page and w_member_social scope; Reddit requires its own OAuth app and respects strict rate limits. Both are doable but slow to set up the first time.
  2. 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()
}
Enter fullscreen mode Exit fullscreen mode

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)
  }
}
Enter fullscreen mode Exit fullscreen mode

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 (14)

Collapse
 
nazar-boyko profile image
Nazar Boyko

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.

Collapse
 
mayu2008 profile image
Mayuresh Smita Suresh

I’ll switch to inserting first with a unique constraint on event_id, then only publish if that insert succeeds. Cleaner than the check-then-act pattern I had. Thanks for pointing it out, updating the post.

Collapse
 
innovationsiyu profile image
Siyu

The triage point is the whole post, honestly. Most agent demos skip it and go straight from fetch to publish, which is how you get accounts that post 30 times a day and wonder why engagement is flat.One extension I'd offer: the same discipline applies to inbound filtering, not just outbound content. If your agent watches for opportunities (clients, collaborators, gigs) instead of news, posting everything is bad, but surfacing everything to your own attention is equally noisy. You just move the triage problem from your audience to yourself.That's the exact problem I was solving with Opportunity Skill. The agent does semantic matching against your actual preferences and boundaries, so only genuinely relevant opportunities reach you. Everything else stays invisible. No inbox flood, no manual sorting.Same principle, different direction. Filter before you act, whether the output is a post or a conversation.

Collapse
 
mayu2008 profile image
Mayuresh Smita Suresh

Filtering inbound opportunities has the same core logic as filtering outbound posts, just pointed the other way. Will check out Opportunity Skills makes sense that semantic matching against real preferences would cut the noise a lot better than manual sorting ever could.

Collapse
 
merbayerp profile image
Mustafa ERBAY

I like that this is presented as a readable pipeline instead of “magic agent” abstractions. For production systems, explicit stages like fetch → triage → generate → dedup → publish are much easier to test, observe, and debug. One thing I’d add is an explicit evaluation and governance layer between generation and publishing. Besides deduplication, I’d validate structured output, enforce policy checks (length, prohibited claims, PII, prompt injection), assign a confidence score, and keep an immutable audit record of the source data, prompt version, model version, and final output. That makes it much easier to explain why something was posted when you need to investigate later.I’d also make the publish step idempotent and resilient to partial failures. If LinkedIn succeeds but Reddit (or the review queue) fails, retries shouldn’t create duplicate posts. A small state machine with retry metadata and idempotency keys goes a long way. Overall, this is one of the more practical AI agent architectures I’ve seen—simple, understandable, and close to how I’d structure a production workflow.

Collapse
 
mayu2008 profile image
Mayuresh Smita Suresh

Really appreciate the depth here. The governance layer is the piece I glossed over validation, policy checks, confidence scoring, and an audit trail (source data + prompt version + model version + output) is exactly what turns “it worked in my demo” into something you can actually defend later. And the idempotency point on publish is well taken partial failures across platforms is a real gap in what I showed. Saving this comment as basically a checklist for the next iteration of the post.

Collapse
 
merbayerp profile image
Mustafa ERBAY

Glad it was useful! Those additions don’t make the agent smarter—they make it trustworthy. Looking forward to the next iteration.

Collapse
 
mightyblue profile image
Mightyblue

The guardrail section is the part I'd move to the top. I learned that one the
expensive way, without any of this infrastructure.

I'm a freelancer in Indonesia. I've published around 40 posts to a dev platform
using AI assistance — no cron, no agent, just me and a chat window, but
functionally the same output pattern. Engagement was close to zero the whole
time. Nothing got flagged, nothing got banned. It just quietly didn't land, and
it took me longer than it should have to understand why: consistent cadence plus
generated copy reads as a feed to skip, even when no rule is broken.

So the failure mode I'd add to your list isn't shadowban. It's the softer one —
the account stays live and simply stops being read. Harder to detect than a ban,
because there's no signal at all. You just see the same flat numbers and assume
you need more volume, which makes it worse.

Which is why your triage step is the right emphasis. But I'd push it further:
the thing that actually got me responses wasn't better generated content, it
was commenting on other people's posts with something only I could say. That's
the one step in the pipeline that doesn't seem automatable, and it might be the
only one that matters.

Collapse
 
krupali_gadhiya profile image
Krupali Gadhiya • Edited

Really liked this. It shows that building useful AI agents is more about designing smart workflows than writing bigger prompts. Great read!

Collapse
 
mayu2008 profile image
Mayuresh Smita Suresh

Thank you

Collapse
 
ketutdana profile image
Ketut Dana

Thanks for the great article!

I’m currently building cloudbanana.de and am actively looking for feedback to improve it. If you have a minute, I’d really appreciate your thoughts on the project!

Collapse
 
mayu2008 profile image
Mayuresh Smita Suresh

Good keep it up

Collapse
 
mudassirworks profile image
Mudassir Khan

the 'watch → think → act → publish' framing is the right mental model but the gap is always state between runs: does the agent know it already posted about this story three days ago?

we solved it with a dead simple kv store keyed by content hash. if the hash exists, skip. no LLM call, no API cost, just a lookup. cloudflare KV fits naturally here since you're already on workers.

how are you handling idempotency on the 'think' step — is Claude reevaluating every fetched story on each cron run, or do you cache the draft decisions too?

Collapse
 
pegasusryug20_2d6ee71068a profile image
Pegasusryug20

This is great, thanks for sharing it