Audit · Arnow Solutions · 05 November 2026

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.

ScopeTwilio + web voice agent, browser intake, written form
Evidence1 live call · 20 Twilio legs · 4 stored intakes
Source2,642 lines, no git
Verdict5.0 / 10 — working prototype

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.

SurfaceWhereHow it was testedResult
Voice agent — webvoice.srv1738752.hstgr.cloudHeadless Chrome, fake mic, 30-second scripted callCompleted; lead + transcript stored
Voice agent — phone+1 929 374 2797Twilio CDR, webhook config, service logsWired correctly; 10 inbound legs on record
Browser intake page/ (voice host)Desktop + 390px render, console, layout, contrast, anti-pattern scanLoads clean; three design defects
Written intakearnowsolutions.com/contactRoute, relay endpoint, form handler, VPS storePage 503; backend healthy
Voice service/opt/arnow-voice · systemdUnit, process, memory, disk, logs, SQLiteRunning as root; data unprotected
72agent sessions in the log
0lead rows before this audit
1call traced end to end
4written intakes ever stored

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.

5.0OF 10

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.

GATES 1 / 10 NO BACKUP STORED XSS LIVE CALL VERIFIED
Conversation & promptRegister, routing, closing, tool fit
6.0
Twilio phone legNumber config, capacity, failure paths
6.0
Browser voice pageClarity, layout, states, accessibility
6.5
Written intakeForm, relay, validation, storage
6.5
Data & persistenceBackups, migrations, retention, export
3.0
Security & privacyAuth, escaping, identity, consent
4.0
Reliability & opsRestarts, limits, logs, monitoring
4.5
PerformancePage weight, turn latency, footprint
7.0
AccessibilityStructure, contrast, motion, labels
5.5
Site integrationIs the agent reachable from the business site?
2.0
DocumentationRunbook accuracy
7.0
Test coverageAutomated proof of the data path
3.0

Quality gate matrix

GateStateEvidence
Deploy cannot destroy production dataFailLead written 19:57, database recreated empty 19:58
Lead store is backed upFailNo copy of voice.db anywhere on the host
Caller-supplied text is escapedFailAdmin template interpolates name, email, summary raw
Identity check resists guessingFail4-digit reference number, unlimited new sessions
Agent reachable from the business siteFailNo embed; CSP and microphone policy forbid it
Every published URL resolvesFailMissing paths return 503, including robots.txt
WCAG AA on the intake pageFailNote text 3.7:1; no h1; animation unguarded
Latency and cost are measuredFailMetrics enabled in pipeline, persisted nowhere
Tests cover the lead pathFailNo unit or integration test; probe scripts only
Runbook matches behaviourPartialREADME 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

P0

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:03 lead 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-voice deletes 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.

Fix: move the DB out of the deploy target (e.g. /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.
P0

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… and connect-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.

Fix: put the widget on a real page (/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.
P0

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_html includes summary, next_action and commitment — 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.
Fix: wrap every interpolated value in html.escape(), add a CSP to the admin response, and move the token to an HttpOnly cookie or basic auth. Effort: 20 minutes.
P0

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_pin returns 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.
Fix: stop treating the reference number as authentication. For web, email a one-time link; for phone, verify last-4 of the number on file or a callback rather than reading notes aloud. Add a global attempt limit per number/IP. Effort: half a day.
P0

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.

URLStatusNote
/contact503Linked from /work/ and /services/
/contact.html200The actual form; works
/about503/about.html returns 200
/pricing, /pricing.html503File missing; error handler returns 503
/voice-demo, /process, /talk503Legacy nav targets
/robots.txt, /sitemap.xml503No crawl directives served at all
/zzz-nonexistent-abc123503Control test — proves it is the handler
Fix: replace the dynamic error document with a static 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

P1

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_intake has no phone property, so the instruction "if they correct it, save the new number" cannot be obeyed.
  • Only take_callback accepts a phone, which creates a callback row instead of enriching the lead.
  • The same prompt runs on both channels; only CHANNEL_NOTE differs.
Fix: delete the hardcoded number, add a 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.
P1

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.
Fix: give the phone channel its own greeting and remove the duplicate instruction from the channel note. Effort: 15 minutes.
P1

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-Signature validation on the webhook or the socket.
  • No status_callback on the number, so failures are invisible outside the CDR.
  • Number has SMS enabled with an empty sms_url: texts disappear silently.
Fix: apply the same semaphore to the phone path, validate Twilio signatures, return a spoken "we're at capacity, leave a number" TwiML when busy, set a status callback, and either wire SMS to a reply or disable it. Effort: 3 hours.
P1

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.transcript holds 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.
Fix: one sentence before the mic ("This call is transcribed and stored so we can send your plan"), a 30-day purge job, and a delete-by-reference path. Effort: 2 hours.
P1

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 as root; unit has no User=, no ProtectSystem, no NoNewPrivileges.
  • MemoryCurrent 726,245,376 bytes with Deepgram active.
  • voice.log uses append: 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.
Fix: run as a dedicated user with systemd hardening, skip model warmup when Deepgram is configured, rotate voice.log, and move the admin token out of the URL. Effort: 2 hours.
P1

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 metrics table; calls stores only seconds, turns, transcript.
  • systemd has Restart=always but no ExecStartPost health probe or external watchdog.
  • Health endpoint reports counts but nothing consumes it.
Fix: log first-token latency and TTS seconds per call into the 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.

KimberlyHi, this is Kimberly, the AI assistant at Arnow Solutions. Are you calling about design, a workflow you want to automate, or something else you want us to look into?
CallerHi there. My name is Shareef, and I run a small medical practice in The Bronx. The scheduling is eating my whole week, we still do it by hand in a spreadsheet.
CallerMy email is Shareef@Example.com.
KimberlyGot it, Shareef. Let me make sure this reaches you — that's Shareef at Example dot com, right?

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.

ObservedValue
Call length / turns29.7 s · 7 turns
Lead outcomecomplete (email + category + details > 40 chars)
Auto-captured emailt+19 s, before the model called save_intake
Post-call enrichment6 fields filled + summary + next action
Console / network errorsnone

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.

The voice intake page at 1440px
Desktop · 1440 × 900 · card 560px · one action
The voice page mid-call with transcript
Mid-call · live transcript polling at 1.4 s

07The Twilio leg

Wired correctly, guarded thinly

SettingValueRead
Number+1 929 374 2797Local NYC number, voice + SMS + MMS capable
Friendly nameArnow Voice IntakeClear
Voice webhookPOST /twilio/voiceCorrect, HTTPS, returns media-stream TwiML
Status callbacknoneNo call events; failures only visible in CDR
SMS URLemptyInbound texts silently dropped
AccountFull · $20.00Funded, created 5 Nov 17:27 UTC
Signature validationnoneWebhook 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.

TimeDirectionDurationInterpretation
18:55:19inbound15 sHandset test, hung up early
19:10:55inbound21 sHandset test, hung up early
19:27–19:39inbound + outbound-api33–110 sSelf-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 happensCaller hearsShould hear
Service downTwilio application error"We can't take the call — text this number and we'll ring you back."
Four simultaneous callsFourth pipeline starts anyway, box saturatesQueue message + callback capture
Caller texts the numberNothingAuto-reply with the form link, or SMS disabled
Caller asks for a humanCallback 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.

CapturedVoice agentWritten form
Nameyesyes
Emailyes, confirmed aloudyes
Phonecaller ID on phone; not savable on webyes, required
Companyyesno
Category / area6-way classifiedno
Timelineyesno
Decision makeryesno
Referral sourceyesno
Social handlesyesno
Commitment madeyes, explicitimplicit only
Summary + next actionwritten after the callraw text only
Company researchBing + LLM, sourcedno
Store / referencevoice.db · 4-digitintake.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

GapWhy it costs a lead
No consent or "what happens next" lineAsks 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 schemaNo area, timeline, budget or decision maker — you cannot triage without a call
No attachment path on the pageThe backend has upload endpoints and a Drive relay; nothing on the form uses them
Failure fallback is an email addresshello@arnowsolutions.com only appears after a failed submit
The route is 503Anyone following the legacy nav, a bookmark or a search result lands on a server-error page

Accessibility and craft, voice page

CheckResult
Horizontal overflow, 390 px and 1440 pxNone; card 350 / 560 px
Touch target112 × 112 px, single action
Accessible name"Start voice call" — does not change while live, so it reads wrong at hang-up
Heading structure0 h1, 1 h2
ContrastNote text 3.7:1 (AA needs 4.5); body 17.2:1; muted 7.0:1
MotionPulse and level meter ignore prefers-reduced-motion
Live statusTranscript log is aria-live; the status line is not
Anti-pattern scan4 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 itemRate5-minute callShare
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.

30 min · revenue-critical

Escape the admin

html.escape() on every interpolated field; add a CSP; move the token out of the query string.

20 min · security

Fix the 503 handler

Static 404.html, real robots and sitemap, repoint legacy nav links off /contact, /process, /talk, /voice-demo.

30 min · credibility + SEO

Put the agent on the site

New /voice-demo page, CSP allow for the voice host, microphone=(self), widget CSS inlined, mobile pass.

half a day · the actual product

Correct the prompt and tool schema

Remove the hardcoded number, add phone to save_intake, split the phone greeting, drop the duplicate opener instruction.

1 h + test call · trust

Gate the phone leg

Concurrency semaphore, Twilio signature check, busy/callback TwiML, status callback, decide SMS.

3 h · resilience

Replace the reference-number lock

Web: emailed link. Phone: verify last-4 of the number on file. Global attempt limit.

half a day · privacy

Consent, retention, export

One line before the mic, 30-day purge, CSV export, delete-by-reference.

2 h · compliance

Version 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.

3 h · every future change

Instrument it

First-token latency, TTS seconds and estimated cost per call in calls; p50/p95 in the admin; an uptime probe.

3 h · you cannot improve what you do not see

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 regressions

12Appendix

Surface, data, and how to re-run the probes

API surface — voice service

MethodPathAuthPurpose
POST/api/offernoneWebRTC offer + ICE; rate-limited 6/h/IP; capacity-checked
PATCH/api/offernoneICE candidates
GET/api/transcript/{pc_id}noneLive transcript polling
POST/twilio/voicenoneInbound TwiML → media stream
WS/ws/twiliononeTwilio media stream; trusts ?from=
POST/twilio/saynonePublic test line — should not ship
GET/healthnoneCounts, 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

TableRows nowNotes
calls1id, start/end, seconds, turns, ip, user_agent, transcript JSON
leads120 columns added by repeated ALTER TABLE; raw duplicates the payload; unique PIN index
intake (written)4Separate 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.