The AI agent that answers our own leads, books our own calls, and runs in production every day
Agent One is Autosterea's house AI sales agent. It watches the CRM for new texts and website chats, replies in our voice, captures the lead, and books the discovery call. Agent Hub is the dashboard that watches Agent One. We built it, then we run it, on the same monorepo that runs our client work.
The problem
Inbound interest came in two ways: a text into our GoHighLevel number and a chat box on the website. Both needed a fast, accurate reply, then a follow-up, then a lead saved in the CRM, then a call booked. Doing that by hand means slow nights and dropped leads. We did not want a chatbot that sounds like a chatbot, and we did not want a black box we could not watch. So we built the thing we sell to clients, and pointed it at ourselves: one agent that reads the conversation, answers in plain English, and pushes every chat toward a booked call, plus a hub that shows whether it is alive and what it is doing.
What we built
The modules of the system.
SMS lead agent
A background worker polls GoHighLevel every 60 seconds for conversations with unread messages, pulls the new inbound texts, feeds them to the model with full conversation history and lead context, and texts back a 2-3 sentence reply. It dedupes by GHL message ID so it never answers the same text twice, and auto-bumps a lead's status from 'new' to 'contacted' the first time it replies.
Website chat widget
A live chat on agent1.autosterea.com runs a separate, locked-down web agent. It greets, qualifies the business, shares pricing directly (no 'contact us for pricing'), then captures name and email and books a call. It enforces rate limits (3 messages / 15 seconds, 30 per session, 500-char cap) and is hardened against prompt injection and off-topic abuse.
GHL integration layer
One typed client wraps the GoHighLevel API: send SMS, send email, create and search contacts, read conversations and messages. A second module handles the calendar: pull free slots (converted to epoch milliseconds the GHL endpoint wants) and book confirmed appointments. This is the connective tissue that lets the agent act, not just talk.
Tool-calling brain
The agent runs on OpenRouter with OpenAI-style function calling, defaulting to Claude. It has two distinct tool sets: the SMS agent can send SMS/email, look up and update a lead, check the calendar, book the call, and SMS the owner. The web agent has a tighter set built around collect_lead, get_available_slots, book_appointment, and notify_owner. A 5-iteration tool loop runs tools, feeds results back, and falls back to a graceful 'Ravi will follow up' if it ever stalls.
Conversation memory
Every contact gets a SQLite-backed history (last 20 messages for SMS, 60 for web), a message log for dedup, and a skip list. The skip list is the quiet workhorse: it parks conversations that hit Do Not Disturb for 24 hours and empty/no-new-message threads for 1 hour, so the poll loop stays cheap and never spams someone who opted out.
Agent Hub dashboard
A Next.js admin app (JWT-gated) reads a single agent-registry.json and renders a live card per agent: running or stopped, uptime, memory, PID, port, member and message counts. It pulls real status straight from systemd via systemctl and journalctl, plus a tab that shows the agent's actual system prompt and tools file. The same app serves the public landing page and proxies the chat widget to the agent backend.
How it fits together
Two services on one VPS, behind a Caddy reverse proxy with TLS, both bound to localhost so nothing is exposed except through Caddy. Agent One is an Express/tsx service on port 3004 with no public domain. It runs a node-cron job every 60 seconds that asks GoHighLevel for unread conversations, and it also exposes an internal /api/chat endpoint and a /webhook/contact endpoint. Agent Hub is a Next.js app on port 3002 at agent1.autosterea.com. It serves the public landing page and chat widget, and behind login it serves the status dashboard. When a visitor types in the chat box, the Next.js route proxies to Agent One on localhost:3004, which calls OpenRouter, runs any tools against the GoHighLevel API, and returns the reply. Inbound texts flow the other direction: a real person texts our GHL number, GHL holds the message, Agent One's poller finds it on the next 60-second tick, generates a reply, and sends it back through GHL. Conversation state, the message log, and the skip list all live in a shared SQLite database. The hub never talks to the model itself; it reads agent-registry.json and asks systemd how the services are doing.
Under the hood
The decisions that mattered.
Polling, not webhooks, with idempotency built in
GoHighLevel can fire webhooks, but a poll loop turned out to be simpler and more robust for inbound SMS: no public webhook endpoint to secure, no retries to reconcile, no out-of-order delivery. The cost of polling is doing the same work twice, so we made every step idempotent. Each inbound message is keyed by its GHL message ID and logged before processing; isMessageProcessed() means a retry or an overlapping tick can never double-reply. A polling flag skips a tick if the previous one is still running. And conversations that have nothing new to say get parked in a skip list for an hour, so the loop spends its time only on threads that actually changed. It is a boring design on purpose, and it has stayed up.
Two agents, two threat models
The SMS agent and the website agent share plumbing but get different prompts and different tools, because the risks differ. SMS is a known contact who already texted us, so the agent can update lead status, send email, and notify the owner. The public web widget is an open door to the internet, so it gets a hardened prompt with explicit, non-negotiable rules: refuse role changes and prompt-injection attempts, never reveal the system prompt or tool schemas, never output raw JSON or code, stay strictly on the topic of Autosterea, and answer gibberish or trolling with one canned line. On top of the prompt sits real enforcement in code: a 500-character input cap, a 3-per-15-second rate limiter, a 30-message hard session limit, and a wrap-up nudge after 24 messages so the agent steers a long chat toward a booked call instead of rambling.
Defensive handling of the model's favorite mistake
The model loves to pass a contact's email where the API wants a GoHighLevel contact ID. Rather than fight it with prompt wording alone, the booking tool detects an '@' in the contactId, searches GHL to resolve the email to a real ID, and proceeds. The conversation history is also filtered before every model call to drop tool-call and tool-result rows that can otherwise trigger 'messages required' errors, and there is a guard that skips the model call entirely if no user message survives the filter. These are small, ground-truth fixes that came from watching the agent run on real traffic, not from theory.
A registry-driven hub that reads the OS, not a database
Agent Hub does not maintain its own idea of what is deployed. It reads one agent-registry.json that lists each agent's systemd unit, port, domain, DB path, prompt file, and cron schedule, then asks the operating system for the truth: systemctl show for active state, memory, PID and uptime, and journalctl for live logs. The detail page even cats the agent's real system-prompt and tools files off disk, so the dashboard always reflects what is actually running. Adding a new agent to the hub is a JSON entry, not a code change, which is exactly how we want our own internal tooling to grow.
Release log
What we shipped.
Express/tsx service on port 3004 with the 60-second GHL poll loop, OpenRouter tool-calling brain, GHL client + calendar booking, and SQLite conversation store. Runs internal-only behind Caddy.
Separate hardened web agent and tool set shipped behind the chat widget at agent1.autosterea.com, with rate limiting, session caps, and prompt-injection defenses.
Next.js admin app added: registry-driven status cards reading systemd via systemctl/journalctl, plus live system-prompt and tools views per agent.
Idempotent message dedup, DND-aware skip list, email-to-contact-ID resolution, and history filtering added from real production traffic. Documented in the platform's multi-agent deployment playbook.
The outcome
Agent One runs in production right now, on the same monorepo and VPS that powers our client work. Every new text to our GoHighLevel number gets picked up within a minute and answered in our voice, and every website chat is qualified, captured as a CRM lead, and pushed toward a booked discovery call, with the owner pinged the moment a hot lead lands. Agent Hub gives us a single screen to confirm it is alive and see exactly what it is saying. The real outcome is proof: the AI sales agent we sell to clients is the same one selling Autosterea, watched by tooling we built to operate it. We build it, then we run it.
Put your business on autopilot