work · multi-agent

shepherd

next.js · mastra · gemini · instagram graph api · github →

one lead · routed by stage setter new · engaged · qualified closer booked · showed · negotiating csm closed · retained burst · one reply, sent as separate texts hey, saw you followed +0.9s what are you building? +1.4s got 15 min tuesday?
the stage is the hand-off: a tool marks a lead booked, and the next turn routes from setter to closer with no human glue. replies land as a human burst, each bubble a beat after the last.

every creator business eventually makes the same three hires: a setter to work inbound dms and book calls, a closer to handle objections and ask for the sale, and a customer success manager to keep the client alive after. shepherd is one whitelabel app that runs all three as ai agents on instagram. the product framing is sales. the engineering question is more interesting: how do you make three different "people" share one customer's history, hand off cleanly, and text like a human and not a chatbot?

one lead, three personalities, routed by stage

there's only ever one entity, a lead, moving through eight pipeline stages. which agent answers is a pure function of the stage:

export function roleForStage(stage) {
  if (stage === "closed") return "csm";
  if (["booked", "showed", "negotiating"].includes(stage)) return "closer";
  return "setter";   // new, engaged, qualified
}

the agents mutate their own stage through tools. when the setter's calendly tool marks a lead booked, the next turn routes to the closer automatically. no human glue, no handoff meeting, the state machine is the handoff. a "client" is just a lead with stage = closed and some gamification columns filled in.

agents are rebuilt every single turn

i never instantiate an agent at module load. every turn, the agent is constructed fresh from (role, tenant config, lead), so its prompt always carries the live offer, the current objection playbook, the persona, and this lead's exact stage and notes. it's also handed a role-scoped subset of tools, the setter and closer get calendly and crm tools, the csm swaps those for gamification tools like completeMilestone. statelessness is what makes multi-tenancy safe: every tool resolves its workspace from the request context and throws if it's missing, so an agent can physically never run untenanted, or touch another tenant's data.

burst texting, the part that sounds human

a bot sends one long paragraph. a person sends three short texts in a row, each landing a beat after the last. so the engine splits the reply on its own newlines into separate dm bubbles and delays each one by a length-scaled pause before sending.

const burstDelay = (text) => Math.min(4500, 700 + text.length * 35);

for (let i = 0; i < bubbles.length; i++) {
  await addMessage(workspaceId, { leadId, role: "agent", text: bubbles[i] });
  if (lead.igUserId) {
    if (i > 0) await sleep(burstDelay(bubbles[i]));   // typing beat
    await sendInstagramDM(workspaceId, lead.igUserId, bubbles[i]);
  }
}

longer thoughts take longer to "type," capped so it never feels broken. it's a small thing that does most of the work of not feeling like a machine. the medium is dms, so the cadence of dms is the product.

the state machine is the hand-off. there's no meeting, no glue, just a lead that crosses a stage and wakes up the next teammate.

the two webhook problems nobody warns you about

going live on instagram surfaced two genuinely hard bugs. first, identity: meta's graph api returns a different id for your own account on /me than it does for that same account inside a conversation's participant list. match naively and the business gets mistaken for the lead. the fix falls back from id to username:

participants.find((p) => p.id === me.id)
  ?? participants.find((p) =>
       p.username?.toLowerCase() === me.username?.toLowerCase())

second, trust: the inbound webhook verifies meta's sha256 signature with a timing-safe compare, and in production a missing secret rejects rather than allows. fail closed, never open. and because meta demands a fast 200, autopilot fires the agent turn off and returns immediately, the reply happens after the acknowledgment.

full parity with no api key

the whole app runs in a demo mode that returns not just a believable reply but a patch of the exact lead-state changes a real agent's tools would have made, so the pipeline and dashboard stay alive with zero keys. that same path is the live-failure fallback. i'm honest about the rough edges, follow-ups need a cron to run, the plan tier is hardcoded, notes overwrite instead of append, this is a real product mid-build, not a finished one. but the spine, stage-routed stateless agents over a clean multi-tenant boundary, texting in human bursts, is solid.

there's an ethic i hold here too. the agent clones your voice from your sent messages, and autopilot is per-lead and switchable, you can always take over. a tool that speaks for you should sound like you and never trap you. و تعاونوا على البرّ, help one another upon good. the automation should serve the relationship, not replace the care in it.

next · duaos → all work →