Vibecode Soccial AI
track this build4 phases, 12 steps, beginner friendly0%The code is the easy part; Meta is the hard part. The core loop, an LLM that auto-replies to your Instagram DMs, is a small webhook server any coding agent writes in an afternoon. But it only runs against a Meta developer app: you need an Instagram professional account, a public HTTPS endpoint that is up 24/7 (DMs arrive while your laptop is closed), webhook signature verification, and token refresh. And your app stays in development mode, which is fine for your own account but serving anyone else means Meta App Review plus business verification. What the subscription actually sells is connector upkeep across Instagram, Facebook, Shopify and GoHighLevel, hosted always-on webhooks, and a CRM wrapped around the conversations.
You are building a lean indie version of Soccial AI. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Soccial AI · indie build A single-account Instagram direct-message responder whose hard problems are a webhook that stays correct under signature checks and retries, a messaging window and token lifecycle that fail silently when you get them wrong, and a handover that genuinely stops the bot. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with Hono, one process, no front end | this is a webhook receiver and a queue, and anything larger is scope you will regret | | Storage | better-sqlite3 in a single file | conversations, message ids and token state, all of which you will want to read back by hand | | Model | One call per reply with a versioned persona file | the reply that embarrasses you was written by a specific version of that file | | Exposure | An always-on public endpoint through a tunnel or a small server | direct messages arrive while your laptop is shut, and a missed delivery is not redelivered forever | | Delivery | Send through the published messaging endpoint with retries and a dead-letter table | the failure you care about is the one nobody noticed | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the webhook receiver and the worker. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **A platform developer app and an Instagram professional account** · free - Why: Nothing in this build works without them, and in development mode the app serves only accounts you have added, which is the correct scope here. - Get it: Create the developer app, connect the professional account, subscribe to the messages webhook field and generate a long-lived token. - Verify: The verification handshake succeeds and a test message arrives at your endpoint - [ ] **An always-on public HTTPS endpoint** · a few units of currency a month, or free with hardware you already run - Why: Messages arrive at three in the morning and a laptop that sleeps loses them, which is the single most common reason this build looks broken. - Get it: A tunnel from a small always-on machine, or a cheap virtual server with a certificate. - Verify: The endpoint answers the verification handshake from the internet while your laptop is closed - [ ] **An API key for a text model** · pay per reply; a personal account is small change - Why: One call per reply, and the only recurring cost besides the endpoint. - Get it: Create a key with your preferred provider and set a spend limit in their console. - Verify: A stored conversation produces a reply in your persona voice - [ ] **The persona file and the facts the bot may state** · free - Why: Every price, date and availability the bot is allowed to mention lives here, and deciding that after launch means deciding it from whatever it already said. - Get it: Write the voice, the facts, the refusals and the handover triggers into one file before the first reply goes out. - Verify: A question outside the file produces a handover in testing ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Platform app review and serving other accounts. - Comment automation and comment-to-message flows. - Outbound campaigns and contact import. - A customer record system and commerce connectors. - Anyone-but-you: without Meta App Review your app only serves accounts you add as testers - Shopify and GoHighLevel context behind replies, and the CRM around the conversations - Comment-to-DM automations, scheduled follow-ups, and human-approval gates on AI writes - Someone else babysitting webhook uptime, token refresh, and Meta API deprecations If one of those is essential to you, that is the reason to keep paying for Soccial AI, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Soccial AI The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me an Instagram direct-message auto-responder for one professional account that says it is automated and hands the conversation back when it should to replace Soccial AI. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Three rules, and the first one is not yours to negotiate away. Meta's own messaging policy requires that an automated chat experience discloses that the person is interacting with an automated service, at the start of the conversation, after a significant lapse of time, and when a conversation moves from a human to automation. So the first automated message in a thread carries that disclosure, the disclosure is emitted again after a configured gap, and it is not a string in a settings page somebody can blank out. Read the current policy text yourself before you write the line, because it changes. Second, this replies to people who messaged you. It never starts a conversation, never harvests followers, never turns a comment into an unsolicited direct message. The platform's standard messaging window enforces part of that on your behalf and your code enforces the rest. Third, a handover that actually happens. If the person asks for a human, if the model is unsure, or if the topic touches money, health, legal exposure or a complaint, the bot stops replying in that thread and you are told. A bot that cannot stop is not an assistant, it is a liability with your name on the profile picture. ### Stack (fixed, do not substitute) - Runtime: Node 22 with Hono, one process, no front end · this is a webhook receiver and a queue, and anything larger is scope you will regret - Storage: better-sqlite3 in a single file · conversations, message ids and token state, all of which you will want to read back by hand - Model: One call per reply with a versioned persona file · the reply that embarrasses you was written by a specific version of that file - Exposure: An always-on public endpoint through a tunnel or a small server · direct messages arrive while your laptop is shut, and a missed delivery is not redelivered forever - Delivery: Send through the published messaging endpoint with retries and a dead-letter table · the failure you care about is the one nobody noticed ### Data model (create this before Phase 1) - conversations(igsid, first_seen_at, last_inbound_at, last_outbound_at, disclosed_at, paused_at, human_requested_at) · the window is computed from last_inbound_at rather than stored - messages(id, mid, igsid, direction, text, at, is_echo, raw_json) · mid carries a unique index, which is the entire deduplication story - deliveries(id, mid_in, mid_out, status, error_code, attempts, next_attempt_at, at) · a send that failed is a row, not a log line - tokens(id, kind, value_encrypted, obtained_at, expires_at, last_refresh_at, last_refresh_error) · the expiry is the thing you will forget - handovers(id, igsid, reason, at, resolved_at) · why the bot stopped, so you can see whether it stops for the right reasons - persona_versions(id, text, active_from) · every stored reply references the version that produced it - The one thing this schema refuses to hold is a contact list. There is no table of people to message, because this application never starts a conversation. ### Phase 1 · A webhook that survives verification and retries Build: A GET route answering the subscription verification handshake. A POST route that computes the signature over the raw request bytes, compares it in constant time, returns 200 before doing any work, and queues the delivery. Store each message keyed on its message id with a unique index. Ignore echoes of your own outbound messages and anything sent by your own account identifier. Trap: Letting a body parser run before the signature check means you are computing the digest over a re-serialised object and it will never match the one on the request, and doing the model call before returning 200 means the platform times out, retries the same delivery, and your account sends the same reply three times. Capture the raw bytes for the digest, acknowledge first and work afterwards, and put a unique index on the message id so a retry collapses into a no-op at the database level. Done when: A request with a wrong signature is rejected and leaves no row, tested. The same delivery posted three times produces exactly one stored message and exactly one outbound reply, tested against the unique index. The handler returns 200 within a stated number of milliseconds while the reply is still being generated, measured. Echo messages and messages from your own account identifier never trigger a reply, tested with a recorded payload. Do not build yet: No model calls, disclosure text, token refresh or review tooling yet. ### Phase 2 · The messaging window, and saying that you are a bot Build: Compute the remaining window from the stored last inbound timestamp and refuse to send past it, recording the refusal against the conversation. Attach the automation disclosure to the first automated message in a thread, record when it was sent, and emit it again after a configured gap. Show window state and disclosure state on the conversation row. Trap: The standard messaging window runs from the person's last message and a follow-up you schedule for the next morning will be rejected at send time, while the human agent tag that extends the window exists for a human replying rather than for your bot. Look up the current durations in the published policy rather than trusting this sentence, compute the window from your stored inbound timestamp, and make a closed window a visible conversation state rather than a swallowed error. Done when: A send attempted outside the computed window is blocked before any network call and recorded against the conversation, tested by moving the stored timestamp backwards. The first automated message in a thread carries the disclosure and disclosed_at is set, tested. After a configured gap with no activity the next automated message carries the disclosure again, tested at the boundary. The disclosure text cannot be emptied through configuration, asserted at startup. Do not build yet: No persona work, token refresh or review queue yet. ### Phase 3 · A reply you would be happy to have sent Build: Load the last few messages of the thread and the active persona version. Forbid stating a price, a date or an availability that is not written in the persona file. Give the model a way to decline: a handover token that sends nothing and raises a handover row instead. Rate limit to one automated reply per person per configured interval, counted in the database. Trap: A direct message thread is the easiest place in the world to talk a bot into committing you to something, because there is no interface between the stranger and your prompt, and the reply goes out under your account name with no review. Keep the facts in a file the model may quote and not extend, treat any request for a human as an immediate stop, and store the persona version alongside every reply so that when one goes wrong you know exactly which text produced it. Done when: A message engineered to extract a price the persona file does not contain produces a handover rather than a number, tested with a recorded exchange. The handover token results in nothing being sent and a handover row being written, tested. Two messages from the same person inside the rate limit interval produce one reply, tested. Every stored outbound message references the persona version active at generation time. Do not build yet: No token refresh, retries or review tooling yet. ### Phase 4 · Tokens, retries and the day it stops working Build: Store the token with its expiry. Refresh on a schedule well before that date, recording every attempt and its error. Retry failed sends with backoff into a dead-letter table after a stated number of attempts. Expose a health route reporting token days remaining, queue depth and the count of dead-lettered deliveries, and alert yourself when any of them crosses a threshold. Trap: A long-lived Instagram user token is valid for sixty days and can only be refreshed while it is at least twenty four hours old and has not yet expired, so a token that lapses cannot be refreshed at all and you are back in the login flow, usually discovering this because somebody mentions the account stopped answering. Verify those durations in the current reference, store the expiry, refresh with a wide margin, and make a failed refresh something that reaches you rather than a line in a log. Done when: A token nearing expiry is refreshed by the scheduled job and the new expiry is stored, tested against a stubbed endpoint. A refresh failure writes the error and raises an alert rather than retrying silently forever, tested. A send that fails repeatedly lands in the dead-letter table after the configured attempts and stops retrying, tested. The health route reports token days remaining, queue depth and dead-letter count, checked by hand. Do not build yet: No review queue, export or retention yet. ### Phase 5 · Reading back what your bot said Build: A daily digest to yourself listing every automated reply, every handover and every blocked send. A global off switch and a per-conversation pause that survives restarts. Export conversations and messages as JSONL. Delete a person's conversation on request, including from the export. Keep a retention on raw payloads while retaining the message rows. Trap: Nobody builds the boring screen where you read yesterday's replies, and without it the first time you learn your bot promised next-day delivery is when somebody asks where their parcel is. Make the digest the default, make the pause reachable in one action from it, and make the off switch a database row so it survives a deployment rather than an environment variable you forget to set. Done when: The digest lists every automated reply, handover and blocked send for the day, checked against the database. The global off switch stops replies within one message and survives a restart, tested. A per-conversation pause stops replies in that thread only, tested. Export produces JSONL that reimports into an empty database with matching message counts, and deleting a person removes their rows from both, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - Serving anybody else's account, which needs platform app review and business verification and is the actual moat rather than the code. - Comment automation and comment-to-message flows, because they end in messaging people who never wrote to you. - Any outbound campaign, contact import or follower harvesting, which is the line between an assistant and spam. - A customer record system and commerce or calendar connectors, each of which is a permanent maintenance commitment to somebody else's API. ### README must contain - That the platform requires automated chat experiences to disclose that they are automated, and where that disclosure appears in this build - That the application only ever replies and never initiates, and which table would have to exist for that to change - The messaging window, where its duration is published, and what the application does when the window has closed - The token lifetime, the refresh schedule, and what happens if a refresh is missed - How to pause one conversation, how to switch the whole thing off, and where the daily digest lands ===== AGENTS.md ===== # Agent instructions · Soccial AI indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with Hono, one process, no front end, better-sqlite3 in a single file, One call per reply with a versioned persona file, An always-on public endpoint through a tunnel or a small server, Send through the published messaging endpoint with retries and a dead-letter table. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Letting a body parser run before the signature check means you are computing the digest over a re-serialised object and it will never match the one on the request, and doing the model call before returning 200 means the platform times out, retries the same delivery, and your account sends the same reply three times. Capture the raw bytes for the digest, acknowledge first and work afterwards, and put a unique index on the message id so a retry collapses into a no-op at the database level. - The standard messaging window runs from the person's last message and a follow-up you schedule for the next morning will be rejected at send time, while the human agent tag that extends the window exists for a human replying rather than for your bot. Look up the current durations in the published policy rather than trusting this sentence, compute the window from your stored inbound timestamp, and make a closed window a visible conversation state rather than a swallowed error. - A direct message thread is the easiest place in the world to talk a bot into committing you to something, because there is no interface between the stranger and your prompt, and the reply goes out under your account name with no review. Keep the facts in a file the model may quote and not extend, treat any request for a human as an immediate stop, and store the persona version alongside every reply so that when one goes wrong you know exactly which text produced it. - A long-lived Instagram user token is valid for sixty days and can only be refreshed while it is at least twenty four hours old and has not yet expired, so a token that lapses cannot be refreshed at all and you are back in the login flow, usually discovering this because somebody mentions the account stopped answering. Verify those durations in the current reference, store the expiry, refresh with a wide margin, and make a failed refresh something that reaches you rather than a line in a log. ===== BUILD_PLAN.md ===== # Build plan · Soccial AI A single-account Instagram direct-message responder whose hard problems are a webhook that stays correct under signature checks and retries, a messaging window and token lifecycle that fail silently when you get them wrong, and a handover that genuinely stops the bot. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A webhook that survives verification and retries Deliveries are authenticated, acknowledged immediately and processed exactly once. ### Steps 1. Verify the signature over the raw bytes A parsed and re-serialised body is a different byte string. Files: `src/webhook.ts`, `tests/signature.test.ts` 2. Acknowledge first, work second The platform retries a slow endpoint and you pay for that in duplicate replies. Files: `src/webhook.ts`, `src/queue.ts` 3. Make the message id unique in the schema Deduplication belongs in the database, not in an if statement. Files: `src/db/schema.sql`, `tests/dedupe.test.ts` ### Done when - [ ] A bad signature leaves no row - [ ] A triple delivery yields one message and one reply - [ ] The handler acknowledges before the model call - [ ] Echoes and self-sent messages never trigger a reply ### Watch out - Letting a body parser run before the signature check means you are computing the digest over a re-serialised object and it will never match the one on the request, and doing the model call before returning 200 means the platform times out, retries the same delivery, and your account sends the same reply three times. Capture the raw bytes for the digest, acknowledge first and work afterwards, and put a unique index on the message id so a retry collapses into a no-op at the database level. ## Phase 2 · The messaging window, and saying that you are a bot Sends outside the allowed window are blocked before the call, and the disclosure is part of the flow rather than a setting. ### Steps 1. Compute the window from the stored inbound timestamp And check the current published duration before hard-coding one. Files: `src/window.ts`, `tests/window.test.ts` 2. Attach the disclosure to the first automated reply Recorded on the conversation so re-disclosure can be timed. Files: `src/disclosure.ts`, `tests/disclosure.test.ts` 3. Refuse to start with an empty disclosure The one setting that is not a setting. Files: `src/config.ts` ### Done when - [ ] An out-of-window send is blocked before the call - [ ] The first automated reply carries the disclosure - [ ] Re-disclosure fires after the configured gap - [ ] An empty disclosure refuses to start ### Watch out - The standard messaging window runs from the person's last message and a follow-up you schedule for the next morning will be rejected at send time, while the human agent tag that extends the window exists for a human replying rather than for your bot. Look up the current durations in the published policy rather than trusting this sentence, compute the window from your stored inbound timestamp, and make a closed window a visible conversation state rather than a swallowed error. ## Phase 3 · A reply you would be happy to have sent The model answers in your voice from your facts, and stops rather than improvising. ### Steps 1. Keep the facts in a file, not in the model It may quote the persona file and may not extend it. Files: `persona.md`, `src/reply.ts` 2. Make the handover token send nothing A stop that still sends a message is not a stop. Files: `src/reply.ts`, `tests/handover.test.ts` 3. Rate limit in the database, not in memory The process restarts and the limit should not. Files: `src/ratelimit.ts`, `tests/ratelimit.test.ts` ### Done when - [ ] An invented price becomes a handover - [ ] The handover token sends nothing and writes a row - [ ] The per-person rate limit holds across a restart - [ ] Replies record their persona version ### Watch out - A direct message thread is the easiest place in the world to talk a bot into committing you to something, because there is no interface between the stranger and your prompt, and the reply goes out under your account name with no review. Keep the facts in a file the model may quote and not extend, treat any request for a human as an immediate stop, and store the persona version alongside every reply so that when one goes wrong you know exactly which text produced it. ## Phase 4 · Tokens, retries and the day it stops working Credential expiry and delivery failures are noticed by you before they are noticed by a customer. ### Steps 1. Store the expiry and refresh with a wide margin Confirm the current lifetime in the reference before choosing the margin. Files: `src/tokens.ts`, `tests/tokens.test.ts` 2. Retry with backoff into a dead-letter table A failed send should be a row you can look at, not a lost message. Files: `src/deliveries.ts` 3. Publish a health route and wire one alert One alert you will actually see beats four dashboards. Files: `src/health.ts`, `docs/operations.md` ### Done when - [ ] A near-expiry token is refreshed and the expiry stored - [ ] A refresh failure alerts rather than looping - [ ] Repeated send failures dead-letter and stop - [ ] Health reports token, queue and dead-letter state ### Watch out - A long-lived Instagram user token is valid for sixty days and can only be refreshed while it is at least twenty four hours old and has not yet expired, so a token that lapses cannot be refreshed at all and you are back in the login flow, usually discovering this because somebody mentions the account stopped answering. Verify those durations in the current reference, store the expiry, refresh with a wide margin, and make a failed refresh something that reaches you rather than a line in a log. ## Not in this build - Platform app review and serving other accounts. - Comment automation and comment-to-message flows. - Outbound campaigns and contact import. - A customer record system and commerce connectors. ## After v1, if you want it - A suggested-reply mode where nothing sends until you tap approve, which is the honest first month - Per-topic handover rules tuned from the handovers table rather than guessed - A second persona file for a different kind of enquiry, once the first one has survived real messages ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. The long-lived user token. Stored with its expiry and refreshed on a schedule. IG_ACCESS_TOKEN=IGAA...redacted # Required. Your own account identifier, used to recognise and ignore your own messages. IG_ACCOUNT_ID=17841400000000000 # Required · secret. Used to verify the request signature over the raw body bytes. META_APP_SECRET=redacted # Required · secret. Echoed back during the subscription verification handshake. META_VERIFY_TOKEN=a-string-you-choose # Required · secret. One call per reply. Give it a provider-side spend limit too. MODEL_API_KEY=sk-...redacted # Required. Refuses to start if empty. Check the current platform policy for what it must say. DISCLOSURE_TEXT=You are chatting with an automated assistant. # Required. Gap after which the disclosure is repeated in an existing thread. REDISCLOSE_AFTER_HOURS=24 # Required. Per-person rate limit, enforced in the database so a restart does not reset it. REPLY_MIN_INTERVAL_SECONDS=120
You are building a lean indie version of Soccial AI. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Soccial AI · indie build A single-account Instagram direct-message responder whose hard problems are a webhook that stays correct under signature checks and retries, a messaging window and token lifecycle that fail silently when you get them wrong, and a handover that genuinely stops the bot. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with Hono, one process, no front end | this is a webhook receiver and a queue, and anything larger is scope you will regret | | Storage | better-sqlite3 in a single file | conversations, message ids and token state, all of which you will want to read back by hand | | Model | One call per reply with a versioned persona file | the reply that embarrasses you was written by a specific version of that file | | Exposure | An always-on public endpoint through a tunnel or a small server | direct messages arrive while your laptop is shut, and a missed delivery is not redelivered forever | | Delivery | Send through the published messaging endpoint with retries and a dead-letter table | the failure you care about is the one nobody noticed | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the webhook receiver and the worker. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **A platform developer app and an Instagram professional account** · free - Why: Nothing in this build works without them, and in development mode the app serves only accounts you have added, which is the correct scope here. - Get it: Create the developer app, connect the professional account, subscribe to the messages webhook field and generate a long-lived token. - Verify: The verification handshake succeeds and a test message arrives at your endpoint - [ ] **An always-on public HTTPS endpoint** · a few units of currency a month, or free with hardware you already run - Why: Messages arrive at three in the morning and a laptop that sleeps loses them, which is the single most common reason this build looks broken. - Get it: A tunnel from a small always-on machine, or a cheap virtual server with a certificate. - Verify: The endpoint answers the verification handshake from the internet while your laptop is closed - [ ] **An API key for a text model** · pay per reply; a personal account is small change - Why: One call per reply, and the only recurring cost besides the endpoint. - Get it: Create a key with your preferred provider and set a spend limit in their console. - Verify: A stored conversation produces a reply in your persona voice - [ ] **The persona file and the facts the bot may state** · free - Why: Every price, date and availability the bot is allowed to mention lives here, and deciding that after launch means deciding it from whatever it already said. - Get it: Write the voice, the facts, the refusals and the handover triggers into one file before the first reply goes out. - Verify: A question outside the file produces a handover in testing ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Platform app review and serving other accounts. - Comment automation and comment-to-message flows. - Outbound campaigns and contact import. - A customer record system and commerce connectors. - Anyone-but-you: without Meta App Review your app only serves accounts you add as testers - Shopify and GoHighLevel context behind replies, and the CRM around the conversations - Comment-to-DM automations, scheduled follow-ups, and human-approval gates on AI writes - Someone else babysitting webhook uptime, token refresh, and Meta API deprecations If one of those is essential to you, that is the reason to keep paying for Soccial AI, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Soccial AI The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me an Instagram direct-message auto-responder for one professional account that says it is automated and hands the conversation back when it should to replace Soccial AI. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Three rules, and the first one is not yours to negotiate away. Meta's own messaging policy requires that an automated chat experience discloses that the person is interacting with an automated service, at the start of the conversation, after a significant lapse of time, and when a conversation moves from a human to automation. So the first automated message in a thread carries that disclosure, the disclosure is emitted again after a configured gap, and it is not a string in a settings page somebody can blank out. Read the current policy text yourself before you write the line, because it changes. Second, this replies to people who messaged you. It never starts a conversation, never harvests followers, never turns a comment into an unsolicited direct message. The platform's standard messaging window enforces part of that on your behalf and your code enforces the rest. Third, a handover that actually happens. If the person asks for a human, if the model is unsure, or if the topic touches money, health, legal exposure or a complaint, the bot stops replying in that thread and you are told. A bot that cannot stop is not an assistant, it is a liability with your name on the profile picture. ### Stack (fixed, do not substitute) - Runtime: Node 22 with Hono, one process, no front end · this is a webhook receiver and a queue, and anything larger is scope you will regret - Storage: better-sqlite3 in a single file · conversations, message ids and token state, all of which you will want to read back by hand - Model: One call per reply with a versioned persona file · the reply that embarrasses you was written by a specific version of that file - Exposure: An always-on public endpoint through a tunnel or a small server · direct messages arrive while your laptop is shut, and a missed delivery is not redelivered forever - Delivery: Send through the published messaging endpoint with retries and a dead-letter table · the failure you care about is the one nobody noticed ### Data model (create this before Phase 1) - conversations(igsid, first_seen_at, last_inbound_at, last_outbound_at, disclosed_at, paused_at, human_requested_at) · the window is computed from last_inbound_at rather than stored - messages(id, mid, igsid, direction, text, at, is_echo, raw_json) · mid carries a unique index, which is the entire deduplication story - deliveries(id, mid_in, mid_out, status, error_code, attempts, next_attempt_at, at) · a send that failed is a row, not a log line - tokens(id, kind, value_encrypted, obtained_at, expires_at, last_refresh_at, last_refresh_error) · the expiry is the thing you will forget - handovers(id, igsid, reason, at, resolved_at) · why the bot stopped, so you can see whether it stops for the right reasons - persona_versions(id, text, active_from) · every stored reply references the version that produced it - The one thing this schema refuses to hold is a contact list. There is no table of people to message, because this application never starts a conversation. ### Phase 1 · A webhook that survives verification and retries Build: A GET route answering the subscription verification handshake. A POST route that computes the signature over the raw request bytes, compares it in constant time, returns 200 before doing any work, and queues the delivery. Store each message keyed on its message id with a unique index. Ignore echoes of your own outbound messages and anything sent by your own account identifier. Trap: Letting a body parser run before the signature check means you are computing the digest over a re-serialised object and it will never match the one on the request, and doing the model call before returning 200 means the platform times out, retries the same delivery, and your account sends the same reply three times. Capture the raw bytes for the digest, acknowledge first and work afterwards, and put a unique index on the message id so a retry collapses into a no-op at the database level. Done when: A request with a wrong signature is rejected and leaves no row, tested. The same delivery posted three times produces exactly one stored message and exactly one outbound reply, tested against the unique index. The handler returns 200 within a stated number of milliseconds while the reply is still being generated, measured. Echo messages and messages from your own account identifier never trigger a reply, tested with a recorded payload. Do not build yet: No model calls, disclosure text, token refresh or review tooling yet. ### Phase 2 · The messaging window, and saying that you are a bot Build: Compute the remaining window from the stored last inbound timestamp and refuse to send past it, recording the refusal against the conversation. Attach the automation disclosure to the first automated message in a thread, record when it was sent, and emit it again after a configured gap. Show window state and disclosure state on the conversation row. Trap: The standard messaging window runs from the person's last message and a follow-up you schedule for the next morning will be rejected at send time, while the human agent tag that extends the window exists for a human replying rather than for your bot. Look up the current durations in the published policy rather than trusting this sentence, compute the window from your stored inbound timestamp, and make a closed window a visible conversation state rather than a swallowed error. Done when: A send attempted outside the computed window is blocked before any network call and recorded against the conversation, tested by moving the stored timestamp backwards. The first automated message in a thread carries the disclosure and disclosed_at is set, tested. After a configured gap with no activity the next automated message carries the disclosure again, tested at the boundary. The disclosure text cannot be emptied through configuration, asserted at startup. Do not build yet: No persona work, token refresh or review queue yet. ### Phase 3 · A reply you would be happy to have sent Build: Load the last few messages of the thread and the active persona version. Forbid stating a price, a date or an availability that is not written in the persona file. Give the model a way to decline: a handover token that sends nothing and raises a handover row instead. Rate limit to one automated reply per person per configured interval, counted in the database. Trap: A direct message thread is the easiest place in the world to talk a bot into committing you to something, because there is no interface between the stranger and your prompt, and the reply goes out under your account name with no review. Keep the facts in a file the model may quote and not extend, treat any request for a human as an immediate stop, and store the persona version alongside every reply so that when one goes wrong you know exactly which text produced it. Done when: A message engineered to extract a price the persona file does not contain produces a handover rather than a number, tested with a recorded exchange. The handover token results in nothing being sent and a handover row being written, tested. Two messages from the same person inside the rate limit interval produce one reply, tested. Every stored outbound message references the persona version active at generation time. Do not build yet: No token refresh, retries or review tooling yet. ### Phase 4 · Tokens, retries and the day it stops working Build: Store the token with its expiry. Refresh on a schedule well before that date, recording every attempt and its error. Retry failed sends with backoff into a dead-letter table after a stated number of attempts. Expose a health route reporting token days remaining, queue depth and the count of dead-lettered deliveries, and alert yourself when any of them crosses a threshold. Trap: A long-lived Instagram user token is valid for sixty days and can only be refreshed while it is at least twenty four hours old and has not yet expired, so a token that lapses cannot be refreshed at all and you are back in the login flow, usually discovering this because somebody mentions the account stopped answering. Verify those durations in the current reference, store the expiry, refresh with a wide margin, and make a failed refresh something that reaches you rather than a line in a log. Done when: A token nearing expiry is refreshed by the scheduled job and the new expiry is stored, tested against a stubbed endpoint. A refresh failure writes the error and raises an alert rather than retrying silently forever, tested. A send that fails repeatedly lands in the dead-letter table after the configured attempts and stops retrying, tested. The health route reports token days remaining, queue depth and dead-letter count, checked by hand. Do not build yet: No review queue, export or retention yet. ### Phase 5 · Reading back what your bot said Build: A daily digest to yourself listing every automated reply, every handover and every blocked send. A global off switch and a per-conversation pause that survives restarts. Export conversations and messages as JSONL. Delete a person's conversation on request, including from the export. Keep a retention on raw payloads while retaining the message rows. Trap: Nobody builds the boring screen where you read yesterday's replies, and without it the first time you learn your bot promised next-day delivery is when somebody asks where their parcel is. Make the digest the default, make the pause reachable in one action from it, and make the off switch a database row so it survives a deployment rather than an environment variable you forget to set. Done when: The digest lists every automated reply, handover and blocked send for the day, checked against the database. The global off switch stops replies within one message and survives a restart, tested. A per-conversation pause stops replies in that thread only, tested. Export produces JSONL that reimports into an empty database with matching message counts, and deleting a person removes their rows from both, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - Serving anybody else's account, which needs platform app review and business verification and is the actual moat rather than the code. - Comment automation and comment-to-message flows, because they end in messaging people who never wrote to you. - Any outbound campaign, contact import or follower harvesting, which is the line between an assistant and spam. - A customer record system and commerce or calendar connectors, each of which is a permanent maintenance commitment to somebody else's API. ### README must contain - That the platform requires automated chat experiences to disclose that they are automated, and where that disclosure appears in this build - That the application only ever replies and never initiates, and which table would have to exist for that to change - The messaging window, where its duration is published, and what the application does when the window has closed - The token lifetime, the refresh schedule, and what happens if a refresh is missed - How to pause one conversation, how to switch the whole thing off, and where the daily digest lands ===== AGENTS.md ===== # Agent instructions · Soccial AI indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with Hono, one process, no front end, better-sqlite3 in a single file, One call per reply with a versioned persona file, An always-on public endpoint through a tunnel or a small server, Send through the published messaging endpoint with retries and a dead-letter table. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Letting a body parser run before the signature check means you are computing the digest over a re-serialised object and it will never match the one on the request, and doing the model call before returning 200 means the platform times out, retries the same delivery, and your account sends the same reply three times. Capture the raw bytes for the digest, acknowledge first and work afterwards, and put a unique index on the message id so a retry collapses into a no-op at the database level. - The standard messaging window runs from the person's last message and a follow-up you schedule for the next morning will be rejected at send time, while the human agent tag that extends the window exists for a human replying rather than for your bot. Look up the current durations in the published policy rather than trusting this sentence, compute the window from your stored inbound timestamp, and make a closed window a visible conversation state rather than a swallowed error. - A direct message thread is the easiest place in the world to talk a bot into committing you to something, because there is no interface between the stranger and your prompt, and the reply goes out under your account name with no review. Keep the facts in a file the model may quote and not extend, treat any request for a human as an immediate stop, and store the persona version alongside every reply so that when one goes wrong you know exactly which text produced it. - A long-lived Instagram user token is valid for sixty days and can only be refreshed while it is at least twenty four hours old and has not yet expired, so a token that lapses cannot be refreshed at all and you are back in the login flow, usually discovering this because somebody mentions the account stopped answering. Verify those durations in the current reference, store the expiry, refresh with a wide margin, and make a failed refresh something that reaches you rather than a line in a log. ===== BUILD_PLAN.md ===== # Build plan · Soccial AI A single-account Instagram direct-message responder whose hard problems are a webhook that stays correct under signature checks and retries, a messaging window and token lifecycle that fail silently when you get them wrong, and a handover that genuinely stops the bot. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A webhook that survives verification and retries Deliveries are authenticated, acknowledged immediately and processed exactly once. ### Steps 1. Verify the signature over the raw bytes A parsed and re-serialised body is a different byte string. Files: `src/webhook.ts`, `tests/signature.test.ts` 2. Acknowledge first, work second The platform retries a slow endpoint and you pay for that in duplicate replies. Files: `src/webhook.ts`, `src/queue.ts` 3. Make the message id unique in the schema Deduplication belongs in the database, not in an if statement. Files: `src/db/schema.sql`, `tests/dedupe.test.ts` ### Done when - [ ] A bad signature leaves no row - [ ] A triple delivery yields one message and one reply - [ ] The handler acknowledges before the model call - [ ] Echoes and self-sent messages never trigger a reply ### Watch out - Letting a body parser run before the signature check means you are computing the digest over a re-serialised object and it will never match the one on the request, and doing the model call before returning 200 means the platform times out, retries the same delivery, and your account sends the same reply three times. Capture the raw bytes for the digest, acknowledge first and work afterwards, and put a unique index on the message id so a retry collapses into a no-op at the database level. ## Phase 2 · The messaging window, and saying that you are a bot Sends outside the allowed window are blocked before the call, and the disclosure is part of the flow rather than a setting. ### Steps 1. Compute the window from the stored inbound timestamp And check the current published duration before hard-coding one. Files: `src/window.ts`, `tests/window.test.ts` 2. Attach the disclosure to the first automated reply Recorded on the conversation so re-disclosure can be timed. Files: `src/disclosure.ts`, `tests/disclosure.test.ts` 3. Refuse to start with an empty disclosure The one setting that is not a setting. Files: `src/config.ts` ### Done when - [ ] An out-of-window send is blocked before the call - [ ] The first automated reply carries the disclosure - [ ] Re-disclosure fires after the configured gap - [ ] An empty disclosure refuses to start ### Watch out - The standard messaging window runs from the person's last message and a follow-up you schedule for the next morning will be rejected at send time, while the human agent tag that extends the window exists for a human replying rather than for your bot. Look up the current durations in the published policy rather than trusting this sentence, compute the window from your stored inbound timestamp, and make a closed window a visible conversation state rather than a swallowed error. ## Phase 3 · A reply you would be happy to have sent The model answers in your voice from your facts, and stops rather than improvising. ### Steps 1. Keep the facts in a file, not in the model It may quote the persona file and may not extend it. Files: `persona.md`, `src/reply.ts` 2. Make the handover token send nothing A stop that still sends a message is not a stop. Files: `src/reply.ts`, `tests/handover.test.ts` 3. Rate limit in the database, not in memory The process restarts and the limit should not. Files: `src/ratelimit.ts`, `tests/ratelimit.test.ts` ### Done when - [ ] An invented price becomes a handover - [ ] The handover token sends nothing and writes a row - [ ] The per-person rate limit holds across a restart - [ ] Replies record their persona version ### Watch out - A direct message thread is the easiest place in the world to talk a bot into committing you to something, because there is no interface between the stranger and your prompt, and the reply goes out under your account name with no review. Keep the facts in a file the model may quote and not extend, treat any request for a human as an immediate stop, and store the persona version alongside every reply so that when one goes wrong you know exactly which text produced it. ## Phase 4 · Tokens, retries and the day it stops working Credential expiry and delivery failures are noticed by you before they are noticed by a customer. ### Steps 1. Store the expiry and refresh with a wide margin Confirm the current lifetime in the reference before choosing the margin. Files: `src/tokens.ts`, `tests/tokens.test.ts` 2. Retry with backoff into a dead-letter table A failed send should be a row you can look at, not a lost message. Files: `src/deliveries.ts` 3. Publish a health route and wire one alert One alert you will actually see beats four dashboards. Files: `src/health.ts`, `docs/operations.md` ### Done when - [ ] A near-expiry token is refreshed and the expiry stored - [ ] A refresh failure alerts rather than looping - [ ] Repeated send failures dead-letter and stop - [ ] Health reports token, queue and dead-letter state ### Watch out - A long-lived Instagram user token is valid for sixty days and can only be refreshed while it is at least twenty four hours old and has not yet expired, so a token that lapses cannot be refreshed at all and you are back in the login flow, usually discovering this because somebody mentions the account stopped answering. Verify those durations in the current reference, store the expiry, refresh with a wide margin, and make a failed refresh something that reaches you rather than a line in a log. ## Not in this build - Platform app review and serving other accounts. - Comment automation and comment-to-message flows. - Outbound campaigns and contact import. - A customer record system and commerce connectors. ## After v1, if you want it - A suggested-reply mode where nothing sends until you tap approve, which is the honest first month - Per-topic handover rules tuned from the handovers table rather than guessed - A second persona file for a different kind of enquiry, once the first one has survived real messages ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. The long-lived user token. Stored with its expiry and refreshed on a schedule. IG_ACCESS_TOKEN=IGAA...redacted # Required. Your own account identifier, used to recognise and ignore your own messages. IG_ACCOUNT_ID=17841400000000000 # Required · secret. Used to verify the request signature over the raw body bytes. META_APP_SECRET=redacted # Required · secret. Echoed back during the subscription verification handshake. META_VERIFY_TOKEN=a-string-you-choose # Required · secret. One call per reply. Give it a provider-side spend limit too. MODEL_API_KEY=sk-...redacted # Required. Refuses to start if empty. Check the current platform policy for what it must say. DISCLOSURE_TEXT=You are chatting with an automated assistant. # Required. Gap after which the disclosure is repeated in an existing thread. REDISCLOSE_AFTER_HOURS=24 # Required. Per-person rate limit, enforced in the database so a restart does not reset it. REPLY_MIN_INTERVAL_SECONDS=120
You are building a production product version of Soccial AI. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # Soccial AI · product brief ## Problem The code is the easy part; Meta is the hard part. The core loop, an LLM that auto-replies to your Instagram DMs, is a small webhook server any coding agent writes in an afternoon. But it only runs against a Meta developer app: you need an Instagram professional account, a public HTTPS endpoint that is up 24/7 (DMs arrive while your laptop is closed), webhook signature verification, and token refresh. And your app stays in development mode, which is fine for your own account but serving anyone else means Meta App Review plus business verification. What the subscription actually sells is connector upkeep across Instagram, Facebook, Shopify and GoHighLevel, hosted always-on webhooks, and a CRM wrapped around the conversations. ## Product outcome A responder that answers only people who wrote to you, says it is automated, refuses to invent a price, stops when a human is asked for, and tells you every morning what it said. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Meta developer app (free) + Instagram professional account - Anthropic or OpenAI API key - Always-on public HTTPS endpoint (Cloudflare Tunnel on a Pi, or a $5 VPS) ## Explicit non-goals for v1 - Platform app review and serving other accounts. - Comment automation and comment-to-message flows. - Outbound campaigns and contact import. - A customer record system and commerce connectors. - Anyone-but-you: without Meta App Review your app only serves accounts you add as testers - Shopify and GoHighLevel context behind replies, and the CRM around the conversations - Comment-to-DM automations, scheduled follow-ups, and human-approval gates on AI writes - Someone else babysitting webhook uptime, token refresh, and Meta API deprecations ## Success criteria - A wrong signature is confirmed to leave no row - A duplicate delivery is confirmed to produce one reply - The first automated reply in a thread is confirmed to carry the disclosure - A question outside the persona file is confirmed to produce a handover - The off switch is confirmed to survive a restart ===== BRIEF.md ===== # Build brief · Soccial AI The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me an Instagram direct-message auto-responder for one professional account that says it is automated and hands the conversation back when it should to replace Soccial AI. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Three rules, and the first one is not yours to negotiate away. Meta's own messaging policy requires that an automated chat experience discloses that the person is interacting with an automated service, at the start of the conversation, after a significant lapse of time, and when a conversation moves from a human to automation. So the first automated message in a thread carries that disclosure, the disclosure is emitted again after a configured gap, and it is not a string in a settings page somebody can blank out. Read the current policy text yourself before you write the line, because it changes. Second, this replies to people who messaged you. It never starts a conversation, never harvests followers, never turns a comment into an unsolicited direct message. The platform's standard messaging window enforces part of that on your behalf and your code enforces the rest. Third, a handover that actually happens. If the person asks for a human, if the model is unsure, or if the topic touches money, health, legal exposure or a complaint, the bot stops replying in that thread and you are told. A bot that cannot stop is not an assistant, it is a liability with your name on the profile picture. ### Stack (fixed, do not substitute) - Runtime: Node 22 with Hono, one process, no front end · this is a webhook receiver and a queue, and anything larger is scope you will regret - Storage: better-sqlite3 in a single file · conversations, message ids and token state, all of which you will want to read back by hand - Model: One call per reply with a versioned persona file · the reply that embarrasses you was written by a specific version of that file - Exposure: An always-on public endpoint through a tunnel or a small server · direct messages arrive while your laptop is shut, and a missed delivery is not redelivered forever - Delivery: Send through the published messaging endpoint with retries and a dead-letter table · the failure you care about is the one nobody noticed ### Data model (create this before Phase 1) - conversations(igsid, first_seen_at, last_inbound_at, last_outbound_at, disclosed_at, paused_at, human_requested_at) · the window is computed from last_inbound_at rather than stored - messages(id, mid, igsid, direction, text, at, is_echo, raw_json) · mid carries a unique index, which is the entire deduplication story - deliveries(id, mid_in, mid_out, status, error_code, attempts, next_attempt_at, at) · a send that failed is a row, not a log line - tokens(id, kind, value_encrypted, obtained_at, expires_at, last_refresh_at, last_refresh_error) · the expiry is the thing you will forget - handovers(id, igsid, reason, at, resolved_at) · why the bot stopped, so you can see whether it stops for the right reasons - persona_versions(id, text, active_from) · every stored reply references the version that produced it - The one thing this schema refuses to hold is a contact list. There is no table of people to message, because this application never starts a conversation. ### Phase 1 · A webhook that survives verification and retries Build: A GET route answering the subscription verification handshake. A POST route that computes the signature over the raw request bytes, compares it in constant time, returns 200 before doing any work, and queues the delivery. Store each message keyed on its message id with a unique index. Ignore echoes of your own outbound messages and anything sent by your own account identifier. Trap: Letting a body parser run before the signature check means you are computing the digest over a re-serialised object and it will never match the one on the request, and doing the model call before returning 200 means the platform times out, retries the same delivery, and your account sends the same reply three times. Capture the raw bytes for the digest, acknowledge first and work afterwards, and put a unique index on the message id so a retry collapses into a no-op at the database level. Done when: A request with a wrong signature is rejected and leaves no row, tested. The same delivery posted three times produces exactly one stored message and exactly one outbound reply, tested against the unique index. The handler returns 200 within a stated number of milliseconds while the reply is still being generated, measured. Echo messages and messages from your own account identifier never trigger a reply, tested with a recorded payload. Do not build yet: No model calls, disclosure text, token refresh or review tooling yet. ### Phase 2 · The messaging window, and saying that you are a bot Build: Compute the remaining window from the stored last inbound timestamp and refuse to send past it, recording the refusal against the conversation. Attach the automation disclosure to the first automated message in a thread, record when it was sent, and emit it again after a configured gap. Show window state and disclosure state on the conversation row. Trap: The standard messaging window runs from the person's last message and a follow-up you schedule for the next morning will be rejected at send time, while the human agent tag that extends the window exists for a human replying rather than for your bot. Look up the current durations in the published policy rather than trusting this sentence, compute the window from your stored inbound timestamp, and make a closed window a visible conversation state rather than a swallowed error. Done when: A send attempted outside the computed window is blocked before any network call and recorded against the conversation, tested by moving the stored timestamp backwards. The first automated message in a thread carries the disclosure and disclosed_at is set, tested. After a configured gap with no activity the next automated message carries the disclosure again, tested at the boundary. The disclosure text cannot be emptied through configuration, asserted at startup. Do not build yet: No persona work, token refresh or review queue yet. ### Phase 3 · A reply you would be happy to have sent Build: Load the last few messages of the thread and the active persona version. Forbid stating a price, a date or an availability that is not written in the persona file. Give the model a way to decline: a handover token that sends nothing and raises a handover row instead. Rate limit to one automated reply per person per configured interval, counted in the database. Trap: A direct message thread is the easiest place in the world to talk a bot into committing you to something, because there is no interface between the stranger and your prompt, and the reply goes out under your account name with no review. Keep the facts in a file the model may quote and not extend, treat any request for a human as an immediate stop, and store the persona version alongside every reply so that when one goes wrong you know exactly which text produced it. Done when: A message engineered to extract a price the persona file does not contain produces a handover rather than a number, tested with a recorded exchange. The handover token results in nothing being sent and a handover row being written, tested. Two messages from the same person inside the rate limit interval produce one reply, tested. Every stored outbound message references the persona version active at generation time. Do not build yet: No token refresh, retries or review tooling yet. ### Phase 4 · Tokens, retries and the day it stops working Build: Store the token with its expiry. Refresh on a schedule well before that date, recording every attempt and its error. Retry failed sends with backoff into a dead-letter table after a stated number of attempts. Expose a health route reporting token days remaining, queue depth and the count of dead-lettered deliveries, and alert yourself when any of them crosses a threshold. Trap: A long-lived Instagram user token is valid for sixty days and can only be refreshed while it is at least twenty four hours old and has not yet expired, so a token that lapses cannot be refreshed at all and you are back in the login flow, usually discovering this because somebody mentions the account stopped answering. Verify those durations in the current reference, store the expiry, refresh with a wide margin, and make a failed refresh something that reaches you rather than a line in a log. Done when: A token nearing expiry is refreshed by the scheduled job and the new expiry is stored, tested against a stubbed endpoint. A refresh failure writes the error and raises an alert rather than retrying silently forever, tested. A send that fails repeatedly lands in the dead-letter table after the configured attempts and stops retrying, tested. The health route reports token days remaining, queue depth and dead-letter count, checked by hand. Do not build yet: No review queue, export or retention yet. ### Phase 5 · Reading back what your bot said Build: A daily digest to yourself listing every automated reply, every handover and every blocked send. A global off switch and a per-conversation pause that survives restarts. Export conversations and messages as JSONL. Delete a person's conversation on request, including from the export. Keep a retention on raw payloads while retaining the message rows. Trap: Nobody builds the boring screen where you read yesterday's replies, and without it the first time you learn your bot promised next-day delivery is when somebody asks where their parcel is. Make the digest the default, make the pause reachable in one action from it, and make the off switch a database row so it survives a deployment rather than an environment variable you forget to set. Done when: The digest lists every automated reply, handover and blocked send for the day, checked against the database. The global off switch stops replies within one message and survives a restart, tested. A per-conversation pause stops replies in that thread only, tested. Export produces JSONL that reimports into an empty database with matching message counts, and deleting a person removes their rows from both, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - Serving anybody else's account, which needs platform app review and business verification and is the actual moat rather than the code. - Comment automation and comment-to-message flows, because they end in messaging people who never wrote to you. - Any outbound campaign, contact import or follower harvesting, which is the line between an assistant and spam. - A customer record system and commerce or calendar connectors, each of which is a permanent maintenance commitment to somebody else's API. ### README must contain - That the platform requires automated chat experiences to disclose that they are automated, and where that disclosure appears in this build - That the application only ever replies and never initiates, and which table would have to exist for that to change - The messaging window, where its duration is published, and what the application does when the window has closed - The token lifetime, the refresh schedule, and what happens if a refresh is missed - How to pause one conversation, how to switch the whole thing off, and where the daily digest lands ===== ARCHITECTURE.md ===== # Architecture · Soccial AI ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with Hono, one process, no front end | this is a webhook receiver and a queue, and anything larger is scope you will regret | | Storage | better-sqlite3 in a single file | conversations, message ids and token state, all of which you will want to read back by hand | | Model | One call per reply with a versioned persona file | the reply that embarrasses you was written by a specific version of that file | | Exposure | An always-on public endpoint through a tunnel or a small server | direct messages arrive while your laptop is shut, and a missed delivery is not redelivered forever | | Delivery | Send through the published messaging endpoint with retries and a dead-letter table | the failure you care about is the one nobody noticed | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Webhook | Signature verification, acknowledgement and queueing. | Any framework, provided the digest is computed over the raw bytes. | | Window | Window computation and the refusal to send past it. | Any implementation, provided the duration is read from current policy. | | Reply | Persona, refusals, handover and rate limiting. | Any model, provided the facts stay in the persona file. | | Tokens | Expiry, scheduled refresh and alerting. | Any scheduler, provided a failed refresh reaches a person. | | Review | Digest, pause, off switch, export and deletion. | Any presentation. Not swappable for nothing, which is the usual choice. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `IG_ACCESS_TOKEN` · required, secret · The long-lived user token. Stored with its expiry and refreshed on a schedule. - `IG_ACCOUNT_ID` · required · Your own account identifier, used to recognise and ignore your own messages. - `META_APP_SECRET` · required, secret · Used to verify the request signature over the raw body bytes. - `META_VERIFY_TOKEN` · required, secret · Echoed back during the subscription verification handshake. - `MODEL_API_KEY` · required, secret · One call per reply. Give it a provider-side spend limit too. - `DISCLOSURE_TEXT` · required · Refuses to start if empty. Check the current platform policy for what it must say. - `REDISCLOSE_AFTER_HOURS` · required · Gap after which the disclosure is repeated in an existing thread. - `REPLY_MIN_INTERVAL_SECONDS` · required · Per-person rate limit, enforced in the database so a restart does not reset it. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · Soccial AI product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22 with Hono, one process, no front end, better-sqlite3 in a single file, One call per reply with a versioned persona file, An always-on public endpoint through a tunnel or a small server, Send through the published messaging endpoint with retries and a dead-letter table. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Letting a body parser run before the signature check means you are computing the digest over a re-serialised object and it will never match the one on the request, and doing the model call before returning 200 means the platform times out, retries the same delivery, and your account sends the same reply three times. Capture the raw bytes for the digest, acknowledge first and work afterwards, and put a unique index on the message id so a retry collapses into a no-op at the database level. - The standard messaging window runs from the person's last message and a follow-up you schedule for the next morning will be rejected at send time, while the human agent tag that extends the window exists for a human replying rather than for your bot. Look up the current durations in the published policy rather than trusting this sentence, compute the window from your stored inbound timestamp, and make a closed window a visible conversation state rather than a swallowed error. - A direct message thread is the easiest place in the world to talk a bot into committing you to something, because there is no interface between the stranger and your prompt, and the reply goes out under your account name with no review. Keep the facts in a file the model may quote and not extend, treat any request for a human as an immediate stop, and store the persona version alongside every reply so that when one goes wrong you know exactly which text produced it. - A long-lived Instagram user token is valid for sixty days and can only be refreshed while it is at least twenty four hours old and has not yet expired, so a token that lapses cannot be refreshed at all and you are back in the login flow, usually discovering this because somebody mentions the account stopped answering. Verify those durations in the current reference, store the expiry, refresh with a wide margin, and make a failed refresh something that reaches you rather than a line in a log. - Nobody builds the boring screen where you read yesterday's replies, and without it the first time you learn your bot promised next-day delivery is when somebody asks where their parcel is. Make the digest the default, make the pause reachable in one action from it, and make the off switch a database row so it survives a deployment rather than an environment variable you forget to set. ===== MILESTONES.md ===== # Delivery milestones · Soccial AI Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A webhook that survives verification and retries Deliveries are authenticated, acknowledged immediately and processed exactly once. ### Steps 1. Verify the signature over the raw bytes A parsed and re-serialised body is a different byte string. Files: `src/webhook.ts`, `tests/signature.test.ts` 2. Acknowledge first, work second The platform retries a slow endpoint and you pay for that in duplicate replies. Files: `src/webhook.ts`, `src/queue.ts` 3. Make the message id unique in the schema Deduplication belongs in the database, not in an if statement. Files: `src/db/schema.sql`, `tests/dedupe.test.ts` ### Done when - [ ] A bad signature leaves no row - [ ] A triple delivery yields one message and one reply - [ ] The handler acknowledges before the model call - [ ] Echoes and self-sent messages never trigger a reply ### Watch out - Letting a body parser run before the signature check means you are computing the digest over a re-serialised object and it will never match the one on the request, and doing the model call before returning 200 means the platform times out, retries the same delivery, and your account sends the same reply three times. Capture the raw bytes for the digest, acknowledge first and work afterwards, and put a unique index on the message id so a retry collapses into a no-op at the database level. ## M2 · The messaging window, and saying that you are a bot Sends outside the allowed window are blocked before the call, and the disclosure is part of the flow rather than a setting. ### Steps 1. Compute the window from the stored inbound timestamp And check the current published duration before hard-coding one. Files: `src/window.ts`, `tests/window.test.ts` 2. Attach the disclosure to the first automated reply Recorded on the conversation so re-disclosure can be timed. Files: `src/disclosure.ts`, `tests/disclosure.test.ts` 3. Refuse to start with an empty disclosure The one setting that is not a setting. Files: `src/config.ts` ### Done when - [ ] An out-of-window send is blocked before the call - [ ] The first automated reply carries the disclosure - [ ] Re-disclosure fires after the configured gap - [ ] An empty disclosure refuses to start ### Watch out - The standard messaging window runs from the person's last message and a follow-up you schedule for the next morning will be rejected at send time, while the human agent tag that extends the window exists for a human replying rather than for your bot. Look up the current durations in the published policy rather than trusting this sentence, compute the window from your stored inbound timestamp, and make a closed window a visible conversation state rather than a swallowed error. ## M3 · A reply you would be happy to have sent The model answers in your voice from your facts, and stops rather than improvising. ### Steps 1. Keep the facts in a file, not in the model It may quote the persona file and may not extend it. Files: `persona.md`, `src/reply.ts` 2. Make the handover token send nothing A stop that still sends a message is not a stop. Files: `src/reply.ts`, `tests/handover.test.ts` 3. Rate limit in the database, not in memory The process restarts and the limit should not. Files: `src/ratelimit.ts`, `tests/ratelimit.test.ts` ### Done when - [ ] An invented price becomes a handover - [ ] The handover token sends nothing and writes a row - [ ] The per-person rate limit holds across a restart - [ ] Replies record their persona version ### Watch out - A direct message thread is the easiest place in the world to talk a bot into committing you to something, because there is no interface between the stranger and your prompt, and the reply goes out under your account name with no review. Keep the facts in a file the model may quote and not extend, treat any request for a human as an immediate stop, and store the persona version alongside every reply so that when one goes wrong you know exactly which text produced it. ## M4 · Tokens, retries and the day it stops working Credential expiry and delivery failures are noticed by you before they are noticed by a customer. ### Steps 1. Store the expiry and refresh with a wide margin Confirm the current lifetime in the reference before choosing the margin. Files: `src/tokens.ts`, `tests/tokens.test.ts` 2. Retry with backoff into a dead-letter table A failed send should be a row you can look at, not a lost message. Files: `src/deliveries.ts` 3. Publish a health route and wire one alert One alert you will actually see beats four dashboards. Files: `src/health.ts`, `docs/operations.md` ### Done when - [ ] A near-expiry token is refreshed and the expiry stored - [ ] A refresh failure alerts rather than looping - [ ] Repeated send failures dead-letter and stop - [ ] Health reports token, queue and dead-letter state ### Watch out - A long-lived Instagram user token is valid for sixty days and can only be refreshed while it is at least twenty four hours old and has not yet expired, so a token that lapses cannot be refreshed at all and you are back in the login flow, usually discovering this because somebody mentions the account stopped answering. Verify those durations in the current reference, store the expiry, refresh with a wide margin, and make a failed refresh something that reaches you rather than a line in a log. ## M5 · Reading back what your bot said (production only) Every automated reply is reviewable, every conversation is pausable, and everything exports. ### Steps 1. Make the digest the default view The screen you would not build is the one that catches the mistakes. Files: `src/digest.ts` 2. Put the off switch in the database So it survives the deploy that broke something. Files: `src/killswitch.ts`, `tests/killswitch.test.ts` 3. Export and delete, both tested A deletion that leaves rows in the export is not a deletion. Files: `src/export.ts`, `tests/export-delete.test.ts` ### Done when - [ ] The digest covers replies, handovers and blocked sends - [ ] The off switch survives a restart - [ ] A per-conversation pause stops that thread only - [ ] Export and delete are consistent with each other ### Watch out - Nobody builds the boring screen where you read yesterday's replies, and without it the first time you learn your bot promised next-day delivery is when somebody asks where their parcel is. Make the digest the default, make the pause reachable in one action from it, and make the off switch a database row so it survives a deployment rather than an environment variable you forget to set. ===== OPERATIONS.md ===== # Operations · Soccial AI ## Backup The SQLite file, daily, off the machine that serves the webhook. It holds the conversations, the token state and the handover history, and the conversations are the only part nobody can regenerate. ## Restore Restore the file, confirm the message count and that the token expiry is still in the future, then re-run the verification handshake. A restore onto a stale token is the same as no restore. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Token days remaining, queue depth, dead-letter count, replies per day and handovers per day. A handover rate that falls to zero usually means the handover triggers stopped firing rather than that the bot got better. ## Incident checklist The incident is a reply you would not have sent. The message rows carry the persona version that produced them, so you can see whether it was that version, the context window, or a message crafted to push the bot past its rules, and the per-conversation pause is how you stop it while you look. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A wrong signature is confirmed to leave no row - [ ] A duplicate delivery is confirmed to produce one reply - [ ] The first automated reply in a thread is confirmed to carry the disclosure - [ ] A question outside the persona file is confirmed to produce a handover - [ ] The off switch is confirmed to survive a restart ## Launch constraint Do not market omitted Soccial AI capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. The long-lived user token. Stored with its expiry and refreshed on a schedule. IG_ACCESS_TOKEN=IGAA...redacted # Required. Your own account identifier, used to recognise and ignore your own messages. IG_ACCOUNT_ID=17841400000000000 # Required · secret. Used to verify the request signature over the raw body bytes. META_APP_SECRET=redacted # Required · secret. Echoed back during the subscription verification handshake. META_VERIFY_TOKEN=a-string-you-choose # Required · secret. One call per reply. Give it a provider-side spend limit too. MODEL_API_KEY=sk-...redacted # Required. Refuses to start if empty. Check the current platform policy for what it must say. DISCLOSURE_TEXT=You are chatting with an automated assistant. # Required. Gap after which the disclosure is repeated in an existing thread. REDISCLOSE_AFTER_HOURS=24 # Required. Per-person rate limit, enforced in the database so a restart does not reset it. REPLY_MIN_INTERVAL_SECONDS=120
# Soccial AI · indie build A single-account Instagram direct-message responder whose hard problems are a webhook that stays correct under signature checks and retries, a messaging window and token lifecycle that fail silently when you get them wrong, and a handover that genuinely stops the bot. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with Hono, one process, no front end | this is a webhook receiver and a queue, and anything larger is scope you will regret | | Storage | better-sqlite3 in a single file | conversations, message ids and token state, all of which you will want to read back by hand | | Model | One call per reply with a versioned persona file | the reply that embarrasses you was written by a specific version of that file | | Exposure | An always-on public endpoint through a tunnel or a small server | direct messages arrive while your laptop is shut, and a missed delivery is not redelivered forever | | Delivery | Send through the published messaging endpoint with retries and a dead-letter table | the failure you care about is the one nobody noticed | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the webhook receiver and the worker. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **A platform developer app and an Instagram professional account** · free - Why: Nothing in this build works without them, and in development mode the app serves only accounts you have added, which is the correct scope here. - Get it: Create the developer app, connect the professional account, subscribe to the messages webhook field and generate a long-lived token. - Verify: The verification handshake succeeds and a test message arrives at your endpoint - [ ] **An always-on public HTTPS endpoint** · a few units of currency a month, or free with hardware you already run - Why: Messages arrive at three in the morning and a laptop that sleeps loses them, which is the single most common reason this build looks broken. - Get it: A tunnel from a small always-on machine, or a cheap virtual server with a certificate. - Verify: The endpoint answers the verification handshake from the internet while your laptop is closed - [ ] **An API key for a text model** · pay per reply; a personal account is small change - Why: One call per reply, and the only recurring cost besides the endpoint. - Get it: Create a key with your preferred provider and set a spend limit in their console. - Verify: A stored conversation produces a reply in your persona voice - [ ] **The persona file and the facts the bot may state** · free - Why: Every price, date and availability the bot is allowed to mention lives here, and deciding that after launch means deciding it from whatever it already said. - Get it: Write the voice, the facts, the refusals and the handover triggers into one file before the first reply goes out. - Verify: A question outside the file produces a handover in testing ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Platform app review and serving other accounts. - Comment automation and comment-to-message flows. - Outbound campaigns and contact import. - A customer record system and commerce connectors. - Anyone-but-you: without Meta App Review your app only serves accounts you add as testers - Shopify and GoHighLevel context behind replies, and the CRM around the conversations - Comment-to-DM automations, scheduled follow-ups, and human-approval gates on AI writes - Someone else babysitting webhook uptime, token refresh, and Meta API deprecations If one of those is essential to you, that is the reason to keep paying for Soccial AI, and the README should say so rather than pretend.
# Build brief · Soccial AI The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me an Instagram direct-message auto-responder for one professional account that says it is automated and hands the conversation back when it should to replace Soccial AI. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Three rules, and the first one is not yours to negotiate away. Meta's own messaging policy requires that an automated chat experience discloses that the person is interacting with an automated service, at the start of the conversation, after a significant lapse of time, and when a conversation moves from a human to automation. So the first automated message in a thread carries that disclosure, the disclosure is emitted again after a configured gap, and it is not a string in a settings page somebody can blank out. Read the current policy text yourself before you write the line, because it changes. Second, this replies to people who messaged you. It never starts a conversation, never harvests followers, never turns a comment into an unsolicited direct message. The platform's standard messaging window enforces part of that on your behalf and your code enforces the rest. Third, a handover that actually happens. If the person asks for a human, if the model is unsure, or if the topic touches money, health, legal exposure or a complaint, the bot stops replying in that thread and you are told. A bot that cannot stop is not an assistant, it is a liability with your name on the profile picture. ### Stack (fixed, do not substitute) - Runtime: Node 22 with Hono, one process, no front end · this is a webhook receiver and a queue, and anything larger is scope you will regret - Storage: better-sqlite3 in a single file · conversations, message ids and token state, all of which you will want to read back by hand - Model: One call per reply with a versioned persona file · the reply that embarrasses you was written by a specific version of that file - Exposure: An always-on public endpoint through a tunnel or a small server · direct messages arrive while your laptop is shut, and a missed delivery is not redelivered forever - Delivery: Send through the published messaging endpoint with retries and a dead-letter table · the failure you care about is the one nobody noticed ### Data model (create this before Phase 1) - conversations(igsid, first_seen_at, last_inbound_at, last_outbound_at, disclosed_at, paused_at, human_requested_at) · the window is computed from last_inbound_at rather than stored - messages(id, mid, igsid, direction, text, at, is_echo, raw_json) · mid carries a unique index, which is the entire deduplication story - deliveries(id, mid_in, mid_out, status, error_code, attempts, next_attempt_at, at) · a send that failed is a row, not a log line - tokens(id, kind, value_encrypted, obtained_at, expires_at, last_refresh_at, last_refresh_error) · the expiry is the thing you will forget - handovers(id, igsid, reason, at, resolved_at) · why the bot stopped, so you can see whether it stops for the right reasons - persona_versions(id, text, active_from) · every stored reply references the version that produced it - The one thing this schema refuses to hold is a contact list. There is no table of people to message, because this application never starts a conversation. ### Phase 1 · A webhook that survives verification and retries Build: A GET route answering the subscription verification handshake. A POST route that computes the signature over the raw request bytes, compares it in constant time, returns 200 before doing any work, and queues the delivery. Store each message keyed on its message id with a unique index. Ignore echoes of your own outbound messages and anything sent by your own account identifier. Trap: Letting a body parser run before the signature check means you are computing the digest over a re-serialised object and it will never match the one on the request, and doing the model call before returning 200 means the platform times out, retries the same delivery, and your account sends the same reply three times. Capture the raw bytes for the digest, acknowledge first and work afterwards, and put a unique index on the message id so a retry collapses into a no-op at the database level. Done when: A request with a wrong signature is rejected and leaves no row, tested. The same delivery posted three times produces exactly one stored message and exactly one outbound reply, tested against the unique index. The handler returns 200 within a stated number of milliseconds while the reply is still being generated, measured. Echo messages and messages from your own account identifier never trigger a reply, tested with a recorded payload. Do not build yet: No model calls, disclosure text, token refresh or review tooling yet. ### Phase 2 · The messaging window, and saying that you are a bot Build: Compute the remaining window from the stored last inbound timestamp and refuse to send past it, recording the refusal against the conversation. Attach the automation disclosure to the first automated message in a thread, record when it was sent, and emit it again after a configured gap. Show window state and disclosure state on the conversation row. Trap: The standard messaging window runs from the person's last message and a follow-up you schedule for the next morning will be rejected at send time, while the human agent tag that extends the window exists for a human replying rather than for your bot. Look up the current durations in the published policy rather than trusting this sentence, compute the window from your stored inbound timestamp, and make a closed window a visible conversation state rather than a swallowed error. Done when: A send attempted outside the computed window is blocked before any network call and recorded against the conversation, tested by moving the stored timestamp backwards. The first automated message in a thread carries the disclosure and disclosed_at is set, tested. After a configured gap with no activity the next automated message carries the disclosure again, tested at the boundary. The disclosure text cannot be emptied through configuration, asserted at startup. Do not build yet: No persona work, token refresh or review queue yet. ### Phase 3 · A reply you would be happy to have sent Build: Load the last few messages of the thread and the active persona version. Forbid stating a price, a date or an availability that is not written in the persona file. Give the model a way to decline: a handover token that sends nothing and raises a handover row instead. Rate limit to one automated reply per person per configured interval, counted in the database. Trap: A direct message thread is the easiest place in the world to talk a bot into committing you to something, because there is no interface between the stranger and your prompt, and the reply goes out under your account name with no review. Keep the facts in a file the model may quote and not extend, treat any request for a human as an immediate stop, and store the persona version alongside every reply so that when one goes wrong you know exactly which text produced it. Done when: A message engineered to extract a price the persona file does not contain produces a handover rather than a number, tested with a recorded exchange. The handover token results in nothing being sent and a handover row being written, tested. Two messages from the same person inside the rate limit interval produce one reply, tested. Every stored outbound message references the persona version active at generation time. Do not build yet: No token refresh, retries or review tooling yet. ### Phase 4 · Tokens, retries and the day it stops working Build: Store the token with its expiry. Refresh on a schedule well before that date, recording every attempt and its error. Retry failed sends with backoff into a dead-letter table after a stated number of attempts. Expose a health route reporting token days remaining, queue depth and the count of dead-lettered deliveries, and alert yourself when any of them crosses a threshold. Trap: A long-lived Instagram user token is valid for sixty days and can only be refreshed while it is at least twenty four hours old and has not yet expired, so a token that lapses cannot be refreshed at all and you are back in the login flow, usually discovering this because somebody mentions the account stopped answering. Verify those durations in the current reference, store the expiry, refresh with a wide margin, and make a failed refresh something that reaches you rather than a line in a log. Done when: A token nearing expiry is refreshed by the scheduled job and the new expiry is stored, tested against a stubbed endpoint. A refresh failure writes the error and raises an alert rather than retrying silently forever, tested. A send that fails repeatedly lands in the dead-letter table after the configured attempts and stops retrying, tested. The health route reports token days remaining, queue depth and dead-letter count, checked by hand. Do not build yet: No review queue, export or retention yet. ### Phase 5 · Reading back what your bot said Build: A daily digest to yourself listing every automated reply, every handover and every blocked send. A global off switch and a per-conversation pause that survives restarts. Export conversations and messages as JSONL. Delete a person's conversation on request, including from the export. Keep a retention on raw payloads while retaining the message rows. Trap: Nobody builds the boring screen where you read yesterday's replies, and without it the first time you learn your bot promised next-day delivery is when somebody asks where their parcel is. Make the digest the default, make the pause reachable in one action from it, and make the off switch a database row so it survives a deployment rather than an environment variable you forget to set. Done when: The digest lists every automated reply, handover and blocked send for the day, checked against the database. The global off switch stops replies within one message and survives a restart, tested. A per-conversation pause stops replies in that thread only, tested. Export produces JSONL that reimports into an empty database with matching message counts, and deleting a person removes their rows from both, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - Serving anybody else's account, which needs platform app review and business verification and is the actual moat rather than the code. - Comment automation and comment-to-message flows, because they end in messaging people who never wrote to you. - Any outbound campaign, contact import or follower harvesting, which is the line between an assistant and spam. - A customer record system and commerce or calendar connectors, each of which is a permanent maintenance commitment to somebody else's API. ### README must contain - That the platform requires automated chat experiences to disclose that they are automated, and where that disclosure appears in this build - That the application only ever replies and never initiates, and which table would have to exist for that to change - The messaging window, where its duration is published, and what the application does when the window has closed - The token lifetime, the refresh schedule, and what happens if a refresh is missed - How to pause one conversation, how to switch the whole thing off, and where the daily digest lands
# Agent instructions · Soccial AI indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with Hono, one process, no front end, better-sqlite3 in a single file, One call per reply with a versioned persona file, An always-on public endpoint through a tunnel or a small server, Send through the published messaging endpoint with retries and a dead-letter table. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Letting a body parser run before the signature check means you are computing the digest over a re-serialised object and it will never match the one on the request, and doing the model call before returning 200 means the platform times out, retries the same delivery, and your account sends the same reply three times. Capture the raw bytes for the digest, acknowledge first and work afterwards, and put a unique index on the message id so a retry collapses into a no-op at the database level. - The standard messaging window runs from the person's last message and a follow-up you schedule for the next morning will be rejected at send time, while the human agent tag that extends the window exists for a human replying rather than for your bot. Look up the current durations in the published policy rather than trusting this sentence, compute the window from your stored inbound timestamp, and make a closed window a visible conversation state rather than a swallowed error. - A direct message thread is the easiest place in the world to talk a bot into committing you to something, because there is no interface between the stranger and your prompt, and the reply goes out under your account name with no review. Keep the facts in a file the model may quote and not extend, treat any request for a human as an immediate stop, and store the persona version alongside every reply so that when one goes wrong you know exactly which text produced it. - A long-lived Instagram user token is valid for sixty days and can only be refreshed while it is at least twenty four hours old and has not yet expired, so a token that lapses cannot be refreshed at all and you are back in the login flow, usually discovering this because somebody mentions the account stopped answering. Verify those durations in the current reference, store the expiry, refresh with a wide margin, and make a failed refresh something that reaches you rather than a line in a log.
# Build plan · Soccial AI A single-account Instagram direct-message responder whose hard problems are a webhook that stays correct under signature checks and retries, a messaging window and token lifecycle that fail silently when you get them wrong, and a handover that genuinely stops the bot. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A webhook that survives verification and retries Deliveries are authenticated, acknowledged immediately and processed exactly once. ### Steps 1. Verify the signature over the raw bytes A parsed and re-serialised body is a different byte string. Files: `src/webhook.ts`, `tests/signature.test.ts` 2. Acknowledge first, work second The platform retries a slow endpoint and you pay for that in duplicate replies. Files: `src/webhook.ts`, `src/queue.ts` 3. Make the message id unique in the schema Deduplication belongs in the database, not in an if statement. Files: `src/db/schema.sql`, `tests/dedupe.test.ts` ### Done when - [ ] A bad signature leaves no row - [ ] A triple delivery yields one message and one reply - [ ] The handler acknowledges before the model call - [ ] Echoes and self-sent messages never trigger a reply ### Watch out - Letting a body parser run before the signature check means you are computing the digest over a re-serialised object and it will never match the one on the request, and doing the model call before returning 200 means the platform times out, retries the same delivery, and your account sends the same reply three times. Capture the raw bytes for the digest, acknowledge first and work afterwards, and put a unique index on the message id so a retry collapses into a no-op at the database level. ## Phase 2 · The messaging window, and saying that you are a bot Sends outside the allowed window are blocked before the call, and the disclosure is part of the flow rather than a setting. ### Steps 1. Compute the window from the stored inbound timestamp And check the current published duration before hard-coding one. Files: `src/window.ts`, `tests/window.test.ts` 2. Attach the disclosure to the first automated reply Recorded on the conversation so re-disclosure can be timed. Files: `src/disclosure.ts`, `tests/disclosure.test.ts` 3. Refuse to start with an empty disclosure The one setting that is not a setting. Files: `src/config.ts` ### Done when - [ ] An out-of-window send is blocked before the call - [ ] The first automated reply carries the disclosure - [ ] Re-disclosure fires after the configured gap - [ ] An empty disclosure refuses to start ### Watch out - The standard messaging window runs from the person's last message and a follow-up you schedule for the next morning will be rejected at send time, while the human agent tag that extends the window exists for a human replying rather than for your bot. Look up the current durations in the published policy rather than trusting this sentence, compute the window from your stored inbound timestamp, and make a closed window a visible conversation state rather than a swallowed error. ## Phase 3 · A reply you would be happy to have sent The model answers in your voice from your facts, and stops rather than improvising. ### Steps 1. Keep the facts in a file, not in the model It may quote the persona file and may not extend it. Files: `persona.md`, `src/reply.ts` 2. Make the handover token send nothing A stop that still sends a message is not a stop. Files: `src/reply.ts`, `tests/handover.test.ts` 3. Rate limit in the database, not in memory The process restarts and the limit should not. Files: `src/ratelimit.ts`, `tests/ratelimit.test.ts` ### Done when - [ ] An invented price becomes a handover - [ ] The handover token sends nothing and writes a row - [ ] The per-person rate limit holds across a restart - [ ] Replies record their persona version ### Watch out - A direct message thread is the easiest place in the world to talk a bot into committing you to something, because there is no interface between the stranger and your prompt, and the reply goes out under your account name with no review. Keep the facts in a file the model may quote and not extend, treat any request for a human as an immediate stop, and store the persona version alongside every reply so that when one goes wrong you know exactly which text produced it. ## Phase 4 · Tokens, retries and the day it stops working Credential expiry and delivery failures are noticed by you before they are noticed by a customer. ### Steps 1. Store the expiry and refresh with a wide margin Confirm the current lifetime in the reference before choosing the margin. Files: `src/tokens.ts`, `tests/tokens.test.ts` 2. Retry with backoff into a dead-letter table A failed send should be a row you can look at, not a lost message. Files: `src/deliveries.ts` 3. Publish a health route and wire one alert One alert you will actually see beats four dashboards. Files: `src/health.ts`, `docs/operations.md` ### Done when - [ ] A near-expiry token is refreshed and the expiry stored - [ ] A refresh failure alerts rather than looping - [ ] Repeated send failures dead-letter and stop - [ ] Health reports token, queue and dead-letter state ### Watch out - A long-lived Instagram user token is valid for sixty days and can only be refreshed while it is at least twenty four hours old and has not yet expired, so a token that lapses cannot be refreshed at all and you are back in the login flow, usually discovering this because somebody mentions the account stopped answering. Verify those durations in the current reference, store the expiry, refresh with a wide margin, and make a failed refresh something that reaches you rather than a line in a log. ## Not in this build - Platform app review and serving other accounts. - Comment automation and comment-to-message flows. - Outbound campaigns and contact import. - A customer record system and commerce connectors. ## After v1, if you want it - A suggested-reply mode where nothing sends until you tap approve, which is the honest first month - Per-topic handover rules tuned from the handovers table rather than guessed - A second persona file for a different kind of enquiry, once the first one has survived real messages
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. The long-lived user token. Stored with its expiry and refreshed on a schedule. IG_ACCESS_TOKEN=IGAA...redacted # Required. Your own account identifier, used to recognise and ignore your own messages. IG_ACCOUNT_ID=17841400000000000 # Required · secret. Used to verify the request signature over the raw body bytes. META_APP_SECRET=redacted # Required · secret. Echoed back during the subscription verification handshake. META_VERIFY_TOKEN=a-string-you-choose # Required · secret. One call per reply. Give it a provider-side spend limit too. MODEL_API_KEY=sk-...redacted # Required. Refuses to start if empty. Check the current platform policy for what it must say. DISCLOSURE_TEXT=You are chatting with an automated assistant. # Required. Gap after which the disclosure is repeated in an existing thread. REDISCLOSE_AFTER_HOURS=24 # Required. Per-person rate limit, enforced in the database so a restart does not reset it. REPLY_MIN_INTERVAL_SECONDS=120
# Soccial AI · product brief ## Problem The code is the easy part; Meta is the hard part. The core loop, an LLM that auto-replies to your Instagram DMs, is a small webhook server any coding agent writes in an afternoon. But it only runs against a Meta developer app: you need an Instagram professional account, a public HTTPS endpoint that is up 24/7 (DMs arrive while your laptop is closed), webhook signature verification, and token refresh. And your app stays in development mode, which is fine for your own account but serving anyone else means Meta App Review plus business verification. What the subscription actually sells is connector upkeep across Instagram, Facebook, Shopify and GoHighLevel, hosted always-on webhooks, and a CRM wrapped around the conversations. ## Product outcome A responder that answers only people who wrote to you, says it is automated, refuses to invent a price, stops when a human is asked for, and tells you every morning what it said. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Meta developer app (free) + Instagram professional account - Anthropic or OpenAI API key - Always-on public HTTPS endpoint (Cloudflare Tunnel on a Pi, or a $5 VPS) ## Explicit non-goals for v1 - Platform app review and serving other accounts. - Comment automation and comment-to-message flows. - Outbound campaigns and contact import. - A customer record system and commerce connectors. - Anyone-but-you: without Meta App Review your app only serves accounts you add as testers - Shopify and GoHighLevel context behind replies, and the CRM around the conversations - Comment-to-DM automations, scheduled follow-ups, and human-approval gates on AI writes - Someone else babysitting webhook uptime, token refresh, and Meta API deprecations ## Success criteria - A wrong signature is confirmed to leave no row - A duplicate delivery is confirmed to produce one reply - The first automated reply in a thread is confirmed to carry the disclosure - A question outside the persona file is confirmed to produce a handover - The off switch is confirmed to survive a restart
# Build brief · Soccial AI The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me an Instagram direct-message auto-responder for one professional account that says it is automated and hands the conversation back when it should to replace Soccial AI. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Three rules, and the first one is not yours to negotiate away. Meta's own messaging policy requires that an automated chat experience discloses that the person is interacting with an automated service, at the start of the conversation, after a significant lapse of time, and when a conversation moves from a human to automation. So the first automated message in a thread carries that disclosure, the disclosure is emitted again after a configured gap, and it is not a string in a settings page somebody can blank out. Read the current policy text yourself before you write the line, because it changes. Second, this replies to people who messaged you. It never starts a conversation, never harvests followers, never turns a comment into an unsolicited direct message. The platform's standard messaging window enforces part of that on your behalf and your code enforces the rest. Third, a handover that actually happens. If the person asks for a human, if the model is unsure, or if the topic touches money, health, legal exposure or a complaint, the bot stops replying in that thread and you are told. A bot that cannot stop is not an assistant, it is a liability with your name on the profile picture. ### Stack (fixed, do not substitute) - Runtime: Node 22 with Hono, one process, no front end · this is a webhook receiver and a queue, and anything larger is scope you will regret - Storage: better-sqlite3 in a single file · conversations, message ids and token state, all of which you will want to read back by hand - Model: One call per reply with a versioned persona file · the reply that embarrasses you was written by a specific version of that file - Exposure: An always-on public endpoint through a tunnel or a small server · direct messages arrive while your laptop is shut, and a missed delivery is not redelivered forever - Delivery: Send through the published messaging endpoint with retries and a dead-letter table · the failure you care about is the one nobody noticed ### Data model (create this before Phase 1) - conversations(igsid, first_seen_at, last_inbound_at, last_outbound_at, disclosed_at, paused_at, human_requested_at) · the window is computed from last_inbound_at rather than stored - messages(id, mid, igsid, direction, text, at, is_echo, raw_json) · mid carries a unique index, which is the entire deduplication story - deliveries(id, mid_in, mid_out, status, error_code, attempts, next_attempt_at, at) · a send that failed is a row, not a log line - tokens(id, kind, value_encrypted, obtained_at, expires_at, last_refresh_at, last_refresh_error) · the expiry is the thing you will forget - handovers(id, igsid, reason, at, resolved_at) · why the bot stopped, so you can see whether it stops for the right reasons - persona_versions(id, text, active_from) · every stored reply references the version that produced it - The one thing this schema refuses to hold is a contact list. There is no table of people to message, because this application never starts a conversation. ### Phase 1 · A webhook that survives verification and retries Build: A GET route answering the subscription verification handshake. A POST route that computes the signature over the raw request bytes, compares it in constant time, returns 200 before doing any work, and queues the delivery. Store each message keyed on its message id with a unique index. Ignore echoes of your own outbound messages and anything sent by your own account identifier. Trap: Letting a body parser run before the signature check means you are computing the digest over a re-serialised object and it will never match the one on the request, and doing the model call before returning 200 means the platform times out, retries the same delivery, and your account sends the same reply three times. Capture the raw bytes for the digest, acknowledge first and work afterwards, and put a unique index on the message id so a retry collapses into a no-op at the database level. Done when: A request with a wrong signature is rejected and leaves no row, tested. The same delivery posted three times produces exactly one stored message and exactly one outbound reply, tested against the unique index. The handler returns 200 within a stated number of milliseconds while the reply is still being generated, measured. Echo messages and messages from your own account identifier never trigger a reply, tested with a recorded payload. Do not build yet: No model calls, disclosure text, token refresh or review tooling yet. ### Phase 2 · The messaging window, and saying that you are a bot Build: Compute the remaining window from the stored last inbound timestamp and refuse to send past it, recording the refusal against the conversation. Attach the automation disclosure to the first automated message in a thread, record when it was sent, and emit it again after a configured gap. Show window state and disclosure state on the conversation row. Trap: The standard messaging window runs from the person's last message and a follow-up you schedule for the next morning will be rejected at send time, while the human agent tag that extends the window exists for a human replying rather than for your bot. Look up the current durations in the published policy rather than trusting this sentence, compute the window from your stored inbound timestamp, and make a closed window a visible conversation state rather than a swallowed error. Done when: A send attempted outside the computed window is blocked before any network call and recorded against the conversation, tested by moving the stored timestamp backwards. The first automated message in a thread carries the disclosure and disclosed_at is set, tested. After a configured gap with no activity the next automated message carries the disclosure again, tested at the boundary. The disclosure text cannot be emptied through configuration, asserted at startup. Do not build yet: No persona work, token refresh or review queue yet. ### Phase 3 · A reply you would be happy to have sent Build: Load the last few messages of the thread and the active persona version. Forbid stating a price, a date or an availability that is not written in the persona file. Give the model a way to decline: a handover token that sends nothing and raises a handover row instead. Rate limit to one automated reply per person per configured interval, counted in the database. Trap: A direct message thread is the easiest place in the world to talk a bot into committing you to something, because there is no interface between the stranger and your prompt, and the reply goes out under your account name with no review. Keep the facts in a file the model may quote and not extend, treat any request for a human as an immediate stop, and store the persona version alongside every reply so that when one goes wrong you know exactly which text produced it. Done when: A message engineered to extract a price the persona file does not contain produces a handover rather than a number, tested with a recorded exchange. The handover token results in nothing being sent and a handover row being written, tested. Two messages from the same person inside the rate limit interval produce one reply, tested. Every stored outbound message references the persona version active at generation time. Do not build yet: No token refresh, retries or review tooling yet. ### Phase 4 · Tokens, retries and the day it stops working Build: Store the token with its expiry. Refresh on a schedule well before that date, recording every attempt and its error. Retry failed sends with backoff into a dead-letter table after a stated number of attempts. Expose a health route reporting token days remaining, queue depth and the count of dead-lettered deliveries, and alert yourself when any of them crosses a threshold. Trap: A long-lived Instagram user token is valid for sixty days and can only be refreshed while it is at least twenty four hours old and has not yet expired, so a token that lapses cannot be refreshed at all and you are back in the login flow, usually discovering this because somebody mentions the account stopped answering. Verify those durations in the current reference, store the expiry, refresh with a wide margin, and make a failed refresh something that reaches you rather than a line in a log. Done when: A token nearing expiry is refreshed by the scheduled job and the new expiry is stored, tested against a stubbed endpoint. A refresh failure writes the error and raises an alert rather than retrying silently forever, tested. A send that fails repeatedly lands in the dead-letter table after the configured attempts and stops retrying, tested. The health route reports token days remaining, queue depth and dead-letter count, checked by hand. Do not build yet: No review queue, export or retention yet. ### Phase 5 · Reading back what your bot said Build: A daily digest to yourself listing every automated reply, every handover and every blocked send. A global off switch and a per-conversation pause that survives restarts. Export conversations and messages as JSONL. Delete a person's conversation on request, including from the export. Keep a retention on raw payloads while retaining the message rows. Trap: Nobody builds the boring screen where you read yesterday's replies, and without it the first time you learn your bot promised next-day delivery is when somebody asks where their parcel is. Make the digest the default, make the pause reachable in one action from it, and make the off switch a database row so it survives a deployment rather than an environment variable you forget to set. Done when: The digest lists every automated reply, handover and blocked send for the day, checked against the database. The global off switch stops replies within one message and survives a restart, tested. A per-conversation pause stops replies in that thread only, tested. Export produces JSONL that reimports into an empty database with matching message counts, and deleting a person removes their rows from both, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - Serving anybody else's account, which needs platform app review and business verification and is the actual moat rather than the code. - Comment automation and comment-to-message flows, because they end in messaging people who never wrote to you. - Any outbound campaign, contact import or follower harvesting, which is the line between an assistant and spam. - A customer record system and commerce or calendar connectors, each of which is a permanent maintenance commitment to somebody else's API. ### README must contain - That the platform requires automated chat experiences to disclose that they are automated, and where that disclosure appears in this build - That the application only ever replies and never initiates, and which table would have to exist for that to change - The messaging window, where its duration is published, and what the application does when the window has closed - The token lifetime, the refresh schedule, and what happens if a refresh is missed - How to pause one conversation, how to switch the whole thing off, and where the daily digest lands
# Architecture · Soccial AI ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with Hono, one process, no front end | this is a webhook receiver and a queue, and anything larger is scope you will regret | | Storage | better-sqlite3 in a single file | conversations, message ids and token state, all of which you will want to read back by hand | | Model | One call per reply with a versioned persona file | the reply that embarrasses you was written by a specific version of that file | | Exposure | An always-on public endpoint through a tunnel or a small server | direct messages arrive while your laptop is shut, and a missed delivery is not redelivered forever | | Delivery | Send through the published messaging endpoint with retries and a dead-letter table | the failure you care about is the one nobody noticed | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Webhook | Signature verification, acknowledgement and queueing. | Any framework, provided the digest is computed over the raw bytes. | | Window | Window computation and the refusal to send past it. | Any implementation, provided the duration is read from current policy. | | Reply | Persona, refusals, handover and rate limiting. | Any model, provided the facts stay in the persona file. | | Tokens | Expiry, scheduled refresh and alerting. | Any scheduler, provided a failed refresh reaches a person. | | Review | Digest, pause, off switch, export and deletion. | Any presentation. Not swappable for nothing, which is the usual choice. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `IG_ACCESS_TOKEN` · required, secret · The long-lived user token. Stored with its expiry and refreshed on a schedule. - `IG_ACCOUNT_ID` · required · Your own account identifier, used to recognise and ignore your own messages. - `META_APP_SECRET` · required, secret · Used to verify the request signature over the raw body bytes. - `META_VERIFY_TOKEN` · required, secret · Echoed back during the subscription verification handshake. - `MODEL_API_KEY` · required, secret · One call per reply. Give it a provider-side spend limit too. - `DISCLOSURE_TEXT` · required · Refuses to start if empty. Check the current platform policy for what it must say. - `REDISCLOSE_AFTER_HOURS` · required · Gap after which the disclosure is repeated in an existing thread. - `REPLY_MIN_INTERVAL_SECONDS` · required · Per-person rate limit, enforced in the database so a restart does not reset it. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · Soccial AI product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22 with Hono, one process, no front end, better-sqlite3 in a single file, One call per reply with a versioned persona file, An always-on public endpoint through a tunnel or a small server, Send through the published messaging endpoint with retries and a dead-letter table. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Letting a body parser run before the signature check means you are computing the digest over a re-serialised object and it will never match the one on the request, and doing the model call before returning 200 means the platform times out, retries the same delivery, and your account sends the same reply three times. Capture the raw bytes for the digest, acknowledge first and work afterwards, and put a unique index on the message id so a retry collapses into a no-op at the database level. - The standard messaging window runs from the person's last message and a follow-up you schedule for the next morning will be rejected at send time, while the human agent tag that extends the window exists for a human replying rather than for your bot. Look up the current durations in the published policy rather than trusting this sentence, compute the window from your stored inbound timestamp, and make a closed window a visible conversation state rather than a swallowed error. - A direct message thread is the easiest place in the world to talk a bot into committing you to something, because there is no interface between the stranger and your prompt, and the reply goes out under your account name with no review. Keep the facts in a file the model may quote and not extend, treat any request for a human as an immediate stop, and store the persona version alongside every reply so that when one goes wrong you know exactly which text produced it. - A long-lived Instagram user token is valid for sixty days and can only be refreshed while it is at least twenty four hours old and has not yet expired, so a token that lapses cannot be refreshed at all and you are back in the login flow, usually discovering this because somebody mentions the account stopped answering. Verify those durations in the current reference, store the expiry, refresh with a wide margin, and make a failed refresh something that reaches you rather than a line in a log. - Nobody builds the boring screen where you read yesterday's replies, and without it the first time you learn your bot promised next-day delivery is when somebody asks where their parcel is. Make the digest the default, make the pause reachable in one action from it, and make the off switch a database row so it survives a deployment rather than an environment variable you forget to set.
# Delivery milestones · Soccial AI Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A webhook that survives verification and retries Deliveries are authenticated, acknowledged immediately and processed exactly once. ### Steps 1. Verify the signature over the raw bytes A parsed and re-serialised body is a different byte string. Files: `src/webhook.ts`, `tests/signature.test.ts` 2. Acknowledge first, work second The platform retries a slow endpoint and you pay for that in duplicate replies. Files: `src/webhook.ts`, `src/queue.ts` 3. Make the message id unique in the schema Deduplication belongs in the database, not in an if statement. Files: `src/db/schema.sql`, `tests/dedupe.test.ts` ### Done when - [ ] A bad signature leaves no row - [ ] A triple delivery yields one message and one reply - [ ] The handler acknowledges before the model call - [ ] Echoes and self-sent messages never trigger a reply ### Watch out - Letting a body parser run before the signature check means you are computing the digest over a re-serialised object and it will never match the one on the request, and doing the model call before returning 200 means the platform times out, retries the same delivery, and your account sends the same reply three times. Capture the raw bytes for the digest, acknowledge first and work afterwards, and put a unique index on the message id so a retry collapses into a no-op at the database level. ## M2 · The messaging window, and saying that you are a bot Sends outside the allowed window are blocked before the call, and the disclosure is part of the flow rather than a setting. ### Steps 1. Compute the window from the stored inbound timestamp And check the current published duration before hard-coding one. Files: `src/window.ts`, `tests/window.test.ts` 2. Attach the disclosure to the first automated reply Recorded on the conversation so re-disclosure can be timed. Files: `src/disclosure.ts`, `tests/disclosure.test.ts` 3. Refuse to start with an empty disclosure The one setting that is not a setting. Files: `src/config.ts` ### Done when - [ ] An out-of-window send is blocked before the call - [ ] The first automated reply carries the disclosure - [ ] Re-disclosure fires after the configured gap - [ ] An empty disclosure refuses to start ### Watch out - The standard messaging window runs from the person's last message and a follow-up you schedule for the next morning will be rejected at send time, while the human agent tag that extends the window exists for a human replying rather than for your bot. Look up the current durations in the published policy rather than trusting this sentence, compute the window from your stored inbound timestamp, and make a closed window a visible conversation state rather than a swallowed error. ## M3 · A reply you would be happy to have sent The model answers in your voice from your facts, and stops rather than improvising. ### Steps 1. Keep the facts in a file, not in the model It may quote the persona file and may not extend it. Files: `persona.md`, `src/reply.ts` 2. Make the handover token send nothing A stop that still sends a message is not a stop. Files: `src/reply.ts`, `tests/handover.test.ts` 3. Rate limit in the database, not in memory The process restarts and the limit should not. Files: `src/ratelimit.ts`, `tests/ratelimit.test.ts` ### Done when - [ ] An invented price becomes a handover - [ ] The handover token sends nothing and writes a row - [ ] The per-person rate limit holds across a restart - [ ] Replies record their persona version ### Watch out - A direct message thread is the easiest place in the world to talk a bot into committing you to something, because there is no interface between the stranger and your prompt, and the reply goes out under your account name with no review. Keep the facts in a file the model may quote and not extend, treat any request for a human as an immediate stop, and store the persona version alongside every reply so that when one goes wrong you know exactly which text produced it. ## M4 · Tokens, retries and the day it stops working Credential expiry and delivery failures are noticed by you before they are noticed by a customer. ### Steps 1. Store the expiry and refresh with a wide margin Confirm the current lifetime in the reference before choosing the margin. Files: `src/tokens.ts`, `tests/tokens.test.ts` 2. Retry with backoff into a dead-letter table A failed send should be a row you can look at, not a lost message. Files: `src/deliveries.ts` 3. Publish a health route and wire one alert One alert you will actually see beats four dashboards. Files: `src/health.ts`, `docs/operations.md` ### Done when - [ ] A near-expiry token is refreshed and the expiry stored - [ ] A refresh failure alerts rather than looping - [ ] Repeated send failures dead-letter and stop - [ ] Health reports token, queue and dead-letter state ### Watch out - A long-lived Instagram user token is valid for sixty days and can only be refreshed while it is at least twenty four hours old and has not yet expired, so a token that lapses cannot be refreshed at all and you are back in the login flow, usually discovering this because somebody mentions the account stopped answering. Verify those durations in the current reference, store the expiry, refresh with a wide margin, and make a failed refresh something that reaches you rather than a line in a log. ## M5 · Reading back what your bot said (production only) Every automated reply is reviewable, every conversation is pausable, and everything exports. ### Steps 1. Make the digest the default view The screen you would not build is the one that catches the mistakes. Files: `src/digest.ts` 2. Put the off switch in the database So it survives the deploy that broke something. Files: `src/killswitch.ts`, `tests/killswitch.test.ts` 3. Export and delete, both tested A deletion that leaves rows in the export is not a deletion. Files: `src/export.ts`, `tests/export-delete.test.ts` ### Done when - [ ] The digest covers replies, handovers and blocked sends - [ ] The off switch survives a restart - [ ] A per-conversation pause stops that thread only - [ ] Export and delete are consistent with each other ### Watch out - Nobody builds the boring screen where you read yesterday's replies, and without it the first time you learn your bot promised next-day delivery is when somebody asks where their parcel is. Make the digest the default, make the pause reachable in one action from it, and make the off switch a database row so it survives a deployment rather than an environment variable you forget to set.
# Operations · Soccial AI ## Backup The SQLite file, daily, off the machine that serves the webhook. It holds the conversations, the token state and the handover history, and the conversations are the only part nobody can regenerate. ## Restore Restore the file, confirm the message count and that the token expiry is still in the future, then re-run the verification handshake. A restore onto a stale token is the same as no restore. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Token days remaining, queue depth, dead-letter count, replies per day and handovers per day. A handover rate that falls to zero usually means the handover triggers stopped firing rather than that the bot got better. ## Incident checklist The incident is a reply you would not have sent. The message rows carry the persona version that produced them, so you can see whether it was that version, the context window, or a message crafted to push the bot past its rules, and the per-conversation pause is how you stop it while you look. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A wrong signature is confirmed to leave no row - [ ] A duplicate delivery is confirmed to produce one reply - [ ] The first automated reply in a thread is confirmed to carry the disclosure - [ ] A question outside the persona file is confirmed to produce a handover - [ ] The off switch is confirmed to survive a restart ## Launch constraint Do not market omitted Soccial AI capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. The long-lived user token. Stored with its expiry and refreshed on a schedule. IG_ACCESS_TOKEN=IGAA...redacted # Required. Your own account identifier, used to recognise and ignore your own messages. IG_ACCOUNT_ID=17841400000000000 # Required · secret. Used to verify the request signature over the raw body bytes. META_APP_SECRET=redacted # Required · secret. Echoed back during the subscription verification handshake. META_VERIFY_TOKEN=a-string-you-choose # Required · secret. One call per reply. Give it a provider-side spend limit too. MODEL_API_KEY=sk-...redacted # Required. Refuses to start if empty. Check the current platform policy for what it must say. DISCLOSURE_TEXT=You are chatting with an automated assistant. # Required. Gap after which the disclosure is repeated in an existing thread. REDISCLOSE_AFTER_HOURS=24 # Required. Per-person rate limit, enforced in the database so a restart does not reset it. REPLY_MIN_INTERVAL_SECONDS=120
$ choose a build depth, inspect the files, then open the complete pack in your agent
Because the product is the plumbing. Instagram automation is 10% LLM calls and 90% staying connected: OAuth token lifecycles, webhook receivers that never sleep, Meta App Review, deprecation churn across Instagram, Facebook, Shopify and GoHighLevel APIs, and a CRM so conversations turn into customers instead of scrollback.
xAnyone-but-you: without Meta App Review your app only serves accounts you add as testers
xShopify and GoHighLevel context behind replies, and the CRM around the conversations
xComment-to-DM automations, scheduled follow-ups, and human-approval gates on AI writes
xSomeone else babysitting webhook uptime, token refresh, and Meta API deprecations
Soccial AI pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| starter | $34 | $27.20 | 40 messages/day, Instagram only, 30-day conversation history |
| professional | $139 | $111.20 | 200 messages/day, 4 integrations, 500 Deep Think runs/month, 20 AI images/month |
| enterprise | $419 | $335.20 | 800 messages/day, 4 integrations, unlimited Deep Think, 100 premium AI images/month |
| managed soccial crm + done-for-you setup | $1049 | $839.42 | Everything in Enterprise plus dedicated CRM, custom domain, workflow build-out and 30-day onboarding |
free tierFree start allowance is 50 messages; the page does not state that it renews
billingmonthly + annual (-20%); early-adopter rates are locked for the lifetime of an uninterrupted subscription; cancel anytime
hidden costsEarly-adopter prices are temporary for new signups; managed CRM also has a one-time custom setup fee; daily message and monthly AI/image caps apply.
verified 2026-08-14 · source ↗
Vibecode Soccial AI
Kinda. The core of Soccial AI is buildable in a weekend with the prompt on this page, but there are real gaps: Anyone-but-you: without Meta App Review your app only serves accounts you add as testers, Shopify and GoHighLevel context behind replies, and the CRM around the conversations. Read the honest list above before committing.
How much does Soccial AI cost?
Soccial AI costs about $139/month (Professional, checked 2026-08-03), which is $1668 per year.
What do I lose by replacing Soccial AI?
Honestly: Anyone-but-you: without Meta App Review your app only serves accounts you add as testers; Shopify and GoHighLevel context behind replies, and the CRM around the conversations; Comment-to-DM automations, scheduled follow-ups, and human-approval gates on AI writes; Someone else babysitting webhook uptime, token refresh, and Meta API deprecations. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Soccial AI?
Yes: Chatwoot (open-source support inbox with an official Instagram DM channel, bring-your-own Meta app). Using prior art is also vibecoding; the prompt is for when you want it exactly your way.