← All writingFeb 8, 2026 · 9 min read

Shipping AI agents into Instagram DMs

One private reply per comment. Webhooks that retry if you are slow. A self-loop that rate-limits you into a 500. The platform is the spec.

Meta APIAgentsWebhooks

Harrir's customers do not open the app to browse. They open Instagram. So the agents went there, and inherited a rulebook written by someone else.

Both webhooks share one shape: verify the signature, return 200 immediately, and do the real work in a background task. Everything below follows from that constraint.

Return 200 before you think

Every handler does three things synchronously. Verify the HMAC signature against the app secret, drop obvious noise like outbound echoes and events with no usable payload, and return 200. The agent run happens afterwards in a background task.

Meta retries non-2xx deliveries aggressively. An LLM's p95 latency inside a webhook response window is how you end up processing the same customer message three times and sending three replies.

The pipeline order is the design

Each inbound DM walks a fixed sequence, and every step exists because skipping it broke something.

  1. Drop echoes and payload-less events.
  2. Resolve story replies, which are not normal attachments and need a separate lookup to recover the image.
  3. Intercept verification codes. A regex prefilter checks whether the message even looks like a code before touching Redis, so ordinary messages never pay for the feature.
  4. Route commands. help, reset and style are answered with fixed replies and never reach the model.
  5. Detect the first turn, so welcome chips can ride the real reply.
  6. Redeem a stashed comment intent, if one is pending for this sender.
  7. Invoke the stylist with the thread keyed to this platform and sender.

The first-turn detail is a good example of the platform dictating structure. There is no separate welcome bubble, because Messenger only shows quick-reply chips on the last bubble of a turn. A standalone greeting would have its chips cleared the moment the real reply landed, so the agent's own prompt greets on turn one and the chips attach to that response.

One private reply per comment

Meta allows exactly one private reply per public comment, within seven days. That single constraint shaped the entire comment path.

Comments do not run the stylist. They run a miniature classifier that picks one of three actions: ignore emoji and noise, reply publicly when the answer needs no product, price or policy, or hand off to a DM.

@tool
async def take_to_dm(kind: str, query: str) -> str:
    """kind in {"products", "brands", "categories", "support"}"""
This tool is never executed. Routing depends only on whether the model called it, and kind selects which teaser template to send.

Running the full stylist on a comment was rejected for two concrete reasons. The product carousel is a DM-only affordance and cannot ride a private-reply payload, so the good answer has nowhere to go. And invoking the stylist would pull in that person's entire DM history, a different and possibly long conversation, risking a context overflow to answer one out-of-context sentence.

Instead the one-shot reply carries a curiosity teaser in the commenter's detected language that names what is waiting, and the original question is stashed in Redis with a seven-day TTL matching Meta's window. When the customer replies in the DM, redemption pops that stashed query and feeds it to the normal pipeline: full agent run, real context management, actual product cards, inside the standard messaging window.

The self-loop that rate-limits you

The comments webhook also fires when the business account comments on its own post. That includes its own auto-replies. Without a filter, the bot replies to itself, which fires the webhook, which replies again, until Meta rate-limits the app with a 500.

The guard is deliberately redundant: compare the commenter id against the page id, and separately check a scoped account id, because the feed payload does not always echo the page id reliably for shared content.

Where a language model is the wrong tool

A small command layer handles the things an LLM should not. help sends a static bubble. reset deletes the thread's LangGraph checkpoints, so it genuinely starts over rather than pretending to. When a quick-reply chip payload and a keyword match disagree, the chip wins: an explicit tap is better evidence than a fuzzy guess about text.

The typing indicator is a small piece of engineering I like. Meta's indicator auto-dismisses after 10 to 15 seconds, so a context manager sends it on entry and re-sends every 8 seconds until the reply is ready. For a typical turn of 1.5 to 2.5 seconds the keepalive is cancelled before it ever fires a second time, so the common case costs exactly one extra API call.

1
private reply allowed per comment
7 days
window, matched by the Redis TTL
8s
typing-indicator keepalive

Almost none of this is agent architecture. It is reading someone else's platform documentation closely enough that their constraints shape your design before production does it for you. The model was the easy part.

Read nextRAG that refuses to guess