The agent works. The intake never reaches it.
Kimberly answers, listens, and writes a real lead to a database. But she is not on arnowsolutions.com — the site's own security headers block a microphone — the contact route returns 503, and the last deploy left the lead store empty.
01Scope & evidence
What was actually tested
This is a scoped audit of the three surfaces a prospective client touches: the phone line, the browser voice page, and the written intake form. Everything below is from live systems on 5 November 2026, not from the README.
| Surface | Where | How it was tested | Result |
|---|---|---|---|
| Voice agent — web | voice.srv1738752.hstgr.cloud | Headless Chrome, fake mic, 30-second scripted call | Completed; lead + transcript stored |
| Voice agent — phone | +1 929 374 2797 | Twilio CDR, webhook config, service logs | Wired correctly; 10 inbound legs on record |
| Browser intake page | / (voice host) | Desktop + 390px render, console, layout, contrast, anti-pattern scan | Loads clean; three design defects |
| Written intake | arnowsolutions.com/contact | Route, relay endpoint, form handler, VPS store | Page 503; backend healthy |
| Voice service | /opt/arnow-voice · systemd | Unit, process, memory, disk, logs, SQLite | Running as root; data unprotected |
The 72-versus-0 gap is not a typo. The log shows sessions from 17:07 onward and a lead written at 19:57:32; the SQLite file on disk was created at 19:58 with the schema and no rows. That is the single most important fact in this report.
02Scorecard
Strong demo, thin production
Scored against what a paying client would expect from a live intake line, not against what the prototype set out to be. The overall is capped by the gate matrix below it, not averaged from opinion.
A working agent bolted to an unready deployment.
The conversation layer is genuinely good and the pipeline is sound. What fails is everything around it: the deploy can erase leads, the admin can be scripted by a caller, the site cannot open a microphone, and the written form's route is down.
Quality gate matrix
| Gate | State | Evidence |
|---|---|---|
| Deploy cannot destroy production data | Fail | Lead written 19:57, database recreated empty 19:58 |
| Lead store is backed up | Fail | No copy of voice.db anywhere on the host |
| Caller-supplied text is escaped | Fail | Admin template interpolates name, email, summary raw |
| Identity check resists guessing | Fail | 4-digit reference number, unlimited new sessions |
| Agent reachable from the business site | Fail | No embed; CSP and microphone policy forbid it |
| Every published URL resolves | Fail | Missing paths return 503, including robots.txt |
| WCAG AA on the intake page | Fail | Note text 3.7:1; no h1; animation unguarded |
| Latency and cost are measured | Fail | Metrics enabled in pipeline, persisted nowhere |
| Tests cover the lead path | Fail | No unit or integration test; probe scripts only |
| Runbook matches behaviour | Partial | README describes a phone greeting the code does not use |
Gate result 1 / 10. Under this desk's scoring rule the overall is the lower of the rubric average (5.4) and the gate ceiling (5.0), so the report ships at 5.0 and is marked not production-ready.
03The five findings that matter
If you fix nothing else, fix these
The lead store was left empty
72 sessions logged, zero rows on disk. The database file's birth time is one minute after the last lead was written. No backup exists.
The agent is not on your website
arnowsolutions.com sends permissions-policy: microphone=() and a CSP that does not allow the voice host. The widget is also unstyled outside its own demo page.
A caller can script your admin panel
Name, email, phone, summary and next action are inserted into the admin HTML without escaping. A spoken name is enough to run script in your browser.
The reference number is the only lock
Four digits, three tries per call, unlimited calls. A guess returns a stranger's name, business, notes and the tail of their last conversation.
Every missing URL returns 503
Contact, about, pricing, robots.txt and sitemap.xml all answer 503. Google reads that as "server broken", not "page gone".
The prompt reads your number back as theirs
Section 8 hardcodes "nine two nine, three seven four, two seven nine seven" as the caller's number — on the web, where there is no number at all — and the save tool has no phone field.
04P0 — fix before the next demo
The last deploy left the lead database empty — and nothing backs it up
This is the finding that should stop a launch. The service has handled 72 sessions. The database the admin page reads contains zero of them, and the file itself was born one minute after the last lead was written.
agent.log—22:03lead written for a live call;19:57:32"filled from transcript on lead d171ea9f777c".voice.db— 98,304 bytes, mtime 19:58, schema only. App files written 19:56–19:57; service restarted 19:58.find / -name "voice.db*"— one file. No backup, no snapshot, no export.- Workspace source has no
data/directory, so any tree-sync from it to/opt/arnow-voicedeletes the production database on the next restart. That is the likely sequence here; the file's creation time is the proof it was replaced, not edited.
A lead store that can be deleted by a copy command is not a lead store. The agent is the only part of this system that cannot be rebuilt from source.
/var/lib/arnow-voice/voice.db), add a nightly sqlite3 .backup with 14-day retention, and make the deploy script refuse to run if the target directory contains a database. Effort: 30 minutes.The voice agent cannot run on arnowsolutions.com as configured
The demo lives on its own subdomain. The business site does not reference widget.js or ArnowVoice anywhere, and if it did, the browser would refuse the microphone.
- Homepage:
grep -i 'ArnowVoice\|widget.js'→ 0 matches. - Response header:
permissions-policy: geolocation=(), microphone=(), camera=(). - CSP:
script-src 'self' https://analytics…andconnect-src 'self' … https://intake…— no voice host. - README: "Styles for the widget live in the demo page today; move them into widget.js before embedding on a light-background page."
So the one thing the agent was built to prove — that a visitor can talk to it from the site — is the one thing a visitor cannot do.
/voice-demo already exists in the old nav and 503s), add https://voice.srv1738752.hstgr.cloud to script-src/connect-src, set microphone=(self), and inline the widget's CSS. Effort: half a day including a mobile pass.Stored XSS in the admin panel — from a caller's spoken name
The admin page is built by string interpolation. Only the research blob is escaped; every field a caller controls is not.
# bot.py, admin row()
f"<tr><td>{r['created_at']}</td><td>{badge}{chip}</td><td>{r['name'] or ''}</td>"
f"<td><b>{r.get('phone') or ''}</b></td><td>{r['email'] or ''}</td>"
f"<td>{need_html}</td>…"
need_htmlincludessummary,next_actionandcommitment— all model output derived from speech.- The admin token is in the URL, so the link you click is the link that executes.
- Admin renders with a 200 and the row when given the token; no CSP on that page.
html.escape(), add a CSP to the admin response, and move the token to an HttpOnly cookie or basic auth. Effort: 20 minutes.A four-digit reference number is the only thing protecting a caller's notes
"Resume where we left off" is a good feature with a weak lock. The reference number is 4 digits, and it returns a stranger's full brief.
find_by_pinreturns name, business, phone, email, details and the last four transcript turns.- Three attempts per session, but a new call resets the counter; the web leg allows 6 calls/hour per IP.
ws://…/ws/twilio?from=+1…trusts a caller-supplied number, so caller-ID recognition can be spoofed without touching Twilio.- PIN space is 9,000 (1000–9999) before the schema silently falls back to six digits.
Every URL that does not exist returns 503 — including the contact page
The marketing site's custom error handler is itself failing, so missing paths return "server temporarily busy" instead of 404. That is an SEO and trust problem, and it is hiding dead links.
| URL | Status | Note |
|---|---|---|
| /contact | 503 | Linked from /work/ and /services/ |
| /contact.html | 200 | The actual form; works |
| /about | 503 | /about.html returns 200 |
| /pricing, /pricing.html | 503 | File missing; error handler returns 503 |
| /voice-demo, /process, /talk | 503 | Legacy nav targets |
| /robots.txt, /sitemap.xml | 503 | No crawl directives served at all |
| /zzz-nonexistent-abc123 | 503 | Control test — proves it is the handler |
404.html in the Hostinger docroot, add a real robots.txt and sitemap.xml, and repoint the legacy nav links. Effort: 30 minutes in hPanel.05P1 — next
The prompt tells Kimberly to read your own number back as the caller's
Section 8 of the system prompt contains a literal example: "I've got you at nine two nine, three seven four, two seven nine seven — is that the best number?" That is the Twilio line, not the caller's number. On the browser leg there is no number on file at all.
save_intakehas nophoneproperty, so the instruction "if they correct it, save the new number" cannot be obeyed.- Only
take_callbackaccepts a phone, which creates a callback row instead of enriching the lead. - The same prompt runs on both channels; only
CHANNEL_NOTEdiffers.
phone field to save_intake, and branch section 8 on channel — phone reads caller ID, web asks once. Effort: 1 hour plus a test call.The phone greeting contradicts its own instructions
The phone channel note says to open with "Thanks for calling Arnow Solutions, this is Kimberly." The actual spoken opener is generated from GREETING["phone"], which is byte-identical to the web greeting and asks "Are you calling about design…?" — an odd question for someone who just dialled you. The model is then told its opening line has already been delivered, and separately told to open with a different sentence.
GREETING["phone"] == GREETING["web"]— same string, verified in source.CHANNEL_NOTE["phone"]instructs a different opener.- README claims the bot says "Thanks for calling Arnow Solutions, this is Kimberly." It does not.
The phone leg has no capacity limit and no failure path
MAX_CONCURRENT = 3 is checked in /api/offer only. Twilio calls go straight to /ws/twilio and start a pipeline regardless of load. There is also no fallback TwiML: if the app is down or saturated, the caller gets dead air or a Twilio error, not a message and a callback promise.
- Concurrency check appears once, inside the WebRTC offer handler.
- No
X-Twilio-Signaturevalidation on the webhook or the socket. - No
status_callbackon the number, so failures are invisible outside the CDR. - Number has SMS enabled with an empty
sms_url: texts disappear silently.
No consent or retention notice, but full transcripts are kept forever
Every call is transcribed, stored with the caller's email and company, and enriched with public-source research. The page says "Nothing records until you press" — true of the microphone, misleading about storage. There is no retention window, no deletion path, and no line telling the caller what happens to their words.
calls.transcriptholds the full message array indefinitely.- Post-call,
research.lookup()scrapes Bing for the caller's company and appends it to the lead. - No privacy line on the voice page or the written form.
The service runs as root, with a 690 MB idle footprint and an unbounded log
A network-facing Python service with an LLM key in its environment should not be UID 0. Separately, startup loads Whisper and Kokoro even though both are unused when Deepgram keys are present — about 690 MB resident for models that never run.
ps— uvicorn PID runs asroot; unit has noUser=, noProtectSystem, noNoNewPrivileges.MemoryCurrent726,245,376 bytes with Deepgram active.voice.logusesappend:with no rotation (385 KB in five hours); disk is 90% full, 21 GB free.- The admin token appears four times in the access log because it travels in the query string.
voice.log, and move the admin token out of the URL. Effort: 2 hours.Nothing measures the thing the agent is judged on
Responsiveness is the whole product. PipelineParams(enable_metrics=True) is set, but the log level is INFO, so metrics are emitted at debug and never written. There is no per-call latency, no TTS seconds, no cost estimate, and no alert if the service stops answering.
- No
metricstable;callsstores only seconds, turns, transcript. - systemd has
Restart=alwaysbut noExecStartPosthealth probe or external watchdog. - Health endpoint reports counts but nothing consumes it.
calls table, show p50/p95 in the admin header, and add a 60-second uptime probe. Effort: 3 hours.06The call itself
What a real 30 seconds sounds like
One synthetic call, placed through the live browser page with a recorded voice. The greeting, the turn-taking and the email read-back all worked. The transcript below is verbatim from calls.transcript.
The register is the strongest part of the build. It listens, it does not perform, and it moves for the email early — the one design decision that most protects the lead. The follow-up pass then filled business, decision-maker and name from the transcript and wrote a 171-character summary plus a next action.
| Observed | Value |
|---|---|
| Call length / turns | 29.7 s · 7 turns |
| Lead outcome | complete (email + category + details > 40 chars) |
| Auto-captured email | t+19 s, before the model called save_intake |
| Post-call enrichment | 6 fields filled + summary + next action |
| Console / network errors | none |
Where the prompt still fights itself
Three instructions cannot all be true at once: section 2 says the opener already asked what they need and must not be repeated; CHANNEL_NOTE["phone"] says to open with a different sentence; section 8 orders a phone read-back using a hardcoded example number. On the web leg the last one has no data behind it, so the most likely failure is Kimberly confidently reading your own line back to a prospect.


07The Twilio leg
Wired correctly, guarded thinly
| Setting | Value | Read |
|---|---|---|
| Number | +1 929 374 2797 | Local NYC number, voice + SMS + MMS capable |
| Friendly name | Arnow Voice Intake | Clear |
| Voice webhook | POST /twilio/voice | Correct, HTTPS, returns media-stream TwiML |
| Status callback | none | No call events; failures only visible in CDR |
| SMS URL | empty | Inbound texts silently dropped |
| Account | Full · $20.00 | Funded, created 5 Nov 17:27 UTC |
| Signature validation | none | Webhook and socket accept anything |
Call detail, 18:55–19:40 UTC
Ten inbound legs. Two are real handset calls (15 s and 21 s from a 34x number). The rest are paired inbound + outbound-api legs of identical duration — the console placing a call from the number to itself. That pairing is why the logs show two media streams per call; it is a test artefact, not a webhook retry.
| Time | Direction | Duration | Interpretation |
|---|---|---|---|
| 18:55:19 | inbound | 15 s | Handset test, hung up early |
| 19:10:55 | inbound | 21 s | Handset test, hung up early |
| 19:27–19:39 | inbound + outbound-api | 33–110 s | Self-calls; both legs answered by the agent |
The phone pipeline itself is correct where it is easy to get wrong: the serializer is told both rates (16 kHz pipeline, 8 kHz Twilio), DeepSeekLLM.supports_developer_role = False is set, and a socket watcher ends silent sessions so slots are not leaked. Those are the details most builds miss.
Failure modes with no path
| If this happens | Caller hears | Should hear |
|---|---|---|
| Service down | Twilio application error | "We can't take the call — text this number and we'll ring you back." |
| Four simultaneous calls | Fourth pipeline starts anyway, box saturates | Queue message + callback capture |
| Caller texts the number | Nothing | Auto-reply with the form link, or SMS disabled |
| Caller asks for a human | Callback captured with name, number, best time | — |
08The intake page
Two front doors, two different houses
There are two ways to become a lead. The voice agent captures ten structured fields and a summary. The written form captures four free-text fields. They do not share a schema, a store, or a reference format.
| Captured | Voice agent | Written form |
|---|---|---|
| Name | yes | yes |
| yes, confirmed aloud | yes | |
| Phone | caller ID on phone; not savable on web | yes, required |
| Company | yes | no |
| Category / area | 6-way classified | no |
| Timeline | yes | no |
| Decision maker | yes | no |
| Referral source | yes | no |
| Social handles | yes | no |
| Commitment made | yes, explicit | implicit only |
| Summary + next action | written after the call | raw text only |
| Company research | Bing + LLM, sourced | no |
| Store / reference | voice.db · 4-digit | intake.db · AS-2026-0001 |
The written path is not broken where people assume it is. The PHP relay answers 400 on an empty body, the VPS endpoint enforces name/email, rate-limits to six per hour per IP, has a honeypot, writes before sending mail ("a failed mail must never lose the lead"), and returns a reference. It has stored four submissions, three of them your own tests.
What the written page is missing
| Gap | Why it costs a lead |
|---|---|
| No consent or "what happens next" line | Asks for a phone number with no promise attached; the success panel is the first time they hear "a person, not an autoresponder" |
| Four fields vs eighteen in the schema | No area, timeline, budget or decision maker — you cannot triage without a call |
| No attachment path on the page | The backend has upload endpoints and a Drive relay; nothing on the form uses them |
| Failure fallback is an email address | hello@arnowsolutions.com only appears after a failed submit |
| The route is 503 | Anyone following the legacy nav, a bookmark or a search result lands on a server-error page |
Accessibility and craft, voice page
| Check | Result |
|---|---|
| Horizontal overflow, 390 px and 1440 px | None; card 350 / 560 px |
| Touch target | 112 × 112 px, single action |
| Accessible name | "Start voice call" — does not change while live, so it reads wrong at hang-up |
| Heading structure | 0 h1, 1 h2 |
| Contrast | Note text 3.7:1 (AA needs 4.5); body 17.2:1; muted 7.0:1 |
| Motion | Pulse and level meter ignore prefers-reduced-motion |
| Live status | Transcript log is aria-live; the status line is not |
| Anti-pattern scan | 4 findings — 1 real contrast failure, 3 × "overused font: Inter" (waived: owner's standard typeface) |
09Cost model
The economics are the best argument for this build
Short version: roughly two to four cents a minute all-in, versus about nine cents a minute for the Vapi platform alone. The margin story holds. Watch the voice, not the brain.
| Line item | Rate | 5-minute call | Share |
|---|---|---|---|
| Deepgram Nova-3 streaming | $0.0077 / min | $0.039 | ~40% |
| Deepgram Aura-2 voice | $0.030 / 1k chars | $0.045–0.080 | ~45% |
| DeepSeek chat | ~$0.28 / M in | $0.010 | ~10% |
| Twilio inbound | $0.0085 / min | $0.043 | ~5% |
| Total per 5-minute call | — | $0.14–0.17 | $0.028–0.034 / min |
At 100 calls a month, five minutes each: about $15–17 in usage plus $1.15 for the number. The equivalent on Vapi at roughly $0.09/minute is $225 before model costs. The build pays for itself in the first month of any real traffic.
Two caveats. TTS is billed per character, so a chatty agent costs more than a slow model would — the prompt's "one sentence is usually enough" is also a cost control. And the fixed cost that actually matters is not usage: it is the 690 MB of idle local models and the unbounded log on a disk that is 90% full.
10What works
Credit where it is due
This is not a rewrite recommendation. Several decisions here are better than what most agencies ship.
The lead is saved before the call ends
LeadCatcher reads the transcript stream for an email and writes a row the moment one appears — independent of the model calling its tool. In the live test it fired at 19 seconds, before save_intake.
The phone-specific traps are handled
Serializer sample rates set explicitly, DeepSeek's missing developer role worked around, silent-socket watchdog so sessions do not leak slots.
The prompt has taste
Ban list for AI vocabulary, one-question rule, examples of right and wrong register, explicit "never say I didn't catch that". This is the part a client will judge.
The written backend does not lose leads
Writes to SQLite before sending mail, records the mail error rather than failing the request, and returns a stable reference.
Post-call enrichment is real
Missing fields are filled from the transcript, a company name is extracted, public research is attributed to source URLs, and a two-line brief is written for the owner.
The page is honest
No fake testimonials, no invented numbers, no "industry-leading". It says what it is and asks for one action.
11Remediation plan
Ordered by what protects revenue
Nothing here requires a redesign. The first three are a morning's work and remove the ways this system can lose a lead or embarrass you in front of one.
Protect the lead store
Move voice.db out of the deploy target, add nightly .backup with 14-day retention, exclude data/ from any sync.
Escape the admin
html.escape() on every interpolated field; add a CSP; move the token out of the query string.
Fix the 503 handler
Static 404.html, real robots and sitemap, repoint legacy nav links off /contact, /process, /talk, /voice-demo.
Put the agent on the site
New /voice-demo page, CSP allow for the voice host, microphone=(self), widget CSS inlined, mobile pass.
Correct the prompt and tool schema
Remove the hardcoded number, add phone to save_intake, split the phone greeting, drop the duplicate opener instruction.
Gate the phone leg
Concurrency semaphore, Twilio signature check, busy/callback TwiML, status callback, decide SMS.
3 h · resilienceReplace the reference-number lock
Web: emailed link. Phone: verify last-4 of the number on file. Global attempt limit.
half a day · privacyConsent, retention, export
One line before the mic, 30-day purge, CSV export, delete-by-reference.
2 h · complianceVersion control and a real deploy
git init, a deploy script that excludes data/ and refuses to overwrite a DB, plus systemd hardening and a dedicated user.
Instrument it
First-token latency, TTS seconds and estimated cost per call in calls; p50/p95 in the admin; an uptime probe.
Test the money path
Unit tests for intake dedupe and PIN lookup; one scripted call in CI that asserts a lead row with an email.
half a day · prevents regressions12Appendix
Surface, data, and how to re-run the probes
API surface — voice service
| Method | Path | Auth | Purpose |
|---|---|---|---|
| POST | /api/offer | none | WebRTC offer + ICE; rate-limited 6/h/IP; capacity-checked |
| PATCH | /api/offer | none | ICE candidates |
| GET | /api/transcript/{pc_id} | none | Live transcript polling |
| POST | /twilio/voice | none | Inbound TwiML → media stream |
| WS | /ws/twilio | none | Twilio media stream; trusts ?from= |
| POST | /twilio/say | none | Public test line — should not ship |
| GET | /health | none | Counts, provider, active calls |
| GET | /admin | ?token= | Lead inbox (200 / 401 verified) |
| GET | /api/leads | ?token= | JSON leads + calls + stats |
| GET | /api/call/{id} | ?token= | Full transcript |
Data model
| Table | Rows now | Notes |
|---|---|---|
| calls | 1 | id, start/end, seconds, turns, ip, user_agent, transcript JSON |
| leads | 1 | 20 columns added by repeated ALTER TABLE; raw duplicates the payload; unique PIN index |
| intake (written) | 4 | Separate SQLite in /opt/intake/data/intake.db, AS-YYYY-NNNN references |
Two lead stores, two reference formats, no shared ID. A caller who uses both the phone and the form appears twice with no way to join them — worth fixing when the schema is next touched, not before.
Re-running the evidence
# service and data curl -s https://voice.srv1738752.hstgr.cloud/health ssh root@147.93.113.241 'sqlite3 -header /opt/arnow-voice/data/voice.db \ "select count(*) from calls; select count(*) from leads;"' # Twilio number + CDR (creds in /opt/arnow-voice/.env) curl -u "$SID:$TOKEN" https://api.twilio.com/2010-04-01/Accounts/$SID/IncomingPhoneNumbers.json # site routing for p in /contact /about /pricing /robots.txt /zzz-nonexistent; do printf "%s " "$p"; curl -s -o /dev/null -w "%{http_code}\n" https://arnowsolutions.com$p done # accessibility gate bash ~/.hermes/scripts/impeccable_detect.sh /workspace/arnow-voice/web
Audit method: source read in full (2,642 lines), live service probed, one synthetic call placed and traced through calls and leads, Twilio CDR pulled for all 20 legs, Hostinger routes tested including a control path, anti-pattern gate run, contrast computed, and a 390 px / 1440 px render captured. The test call's lead was removed after verification so the inbox stayed clean. Scoped audit — infrastructure outside these three surfaces was not reviewed.