The console, the onboarding, and a fresh clone that talks - #4
Conversation
agent/.gitignore ignored '.env.*', which matches '.env.example', so the file was never committed and 'cd agent && cp .env.example .env' failed on a clean clone. Narrow the rule and commit the file. Also drop the leftover placeholder PROJECT_NAME, which was the title every buyer saw on /docs after the compose quickstart, and add setup.json to the ignore list ahead of the /setup command that writes it.
…ored code The cross-sell block claimed a template count, recordings, an outbound-caller template, a Render deploy target, and a task-scaffolder subagent. None of those exist in Pro. It also said Pro sits "on top of this core", which is the opposite of the truth: Pro is a separate repo that shares the stack, not a dependency. Rewrite it around what is actually built, and name the paid product ShipVoice Pro throughout so it stops colliding with this repo's own name. Also add THIRD_PARTY_NOTICES.md. Twelve vendored registry components were sitting under a blanket MIT that is not ours to grant. shadcn is MIT and AI Elements is Apache 2.0; the LiveKit Agents UI registry declares no licence at all, so that is recorded as unresolved rather than guessed.
The console is about to read call transcripts, and a login form alone does not protect them: /users/register is public, so a stranger who finds a deployed instance registers, is no longer anonymous, and reads every row. Two changes close it. Registration is now off unless ALLOW_OPEN_REGISTRATION is set, and the gate runs before the service so nothing is created and then refused. And _require_admin, which callers had to remember to invoke inside a handler body, becomes an AdminUser dependency in a new src/api/deps.py, so a future endpoint that forgets it cannot end up silently public.
src/backend/__init__.py was a 53-byte "Hello from backend!" left over from uv init, reachable only through the [project.scripts] entry that pointed at it. Nothing imports either one: the app resolves src.main through PYTHONPATH, both locally and in the image. Removing the directory alone would break uv, which derives the package to build from the project name and so expects src/backend to exist, so mark the project as an application with tool.uv.package = false. That flips one line in uv.lock from editable to virtual, which has to be committed because the Dockerfile syncs --frozen. Verified: uv sync, 34 tests, ruff, mypy, a docker compose build of the backend image, and /health on the rebuilt container.
…bserve it The console's Agents page needs to know what this deployment runs. The backend can read AGENT_NAME, but the provider stack is compiled into the worker at agent/src/agent.py and lives in a different container, so it cannot be observed from here. Declare it instead, name the file it came from in the response, and add a drift test that reads agent/src/agent.py and fails if the strings stop matching. The alternative was a console that keeps confidently reporting a model the agent stopped using. Reads require a superuser, same gate as the rest of the console.
Replaces the single-screen voice UI with the console ShipVoice Pro ships: same 240px rail, 56px topbar, 48px footer, 4px grid, ShipVoice red, Geist. Watch (Overview, Calls), Run (Campaigns, Channels, Customers), Build (Agents, Evaluations), Settings in the rail footer. The voice call is not gone, it moved. It lives on Agent detail as the test call, still driven by this repo's own vendored LiveKit components rather than Pro's BarVisualizer, because they are better and they were already proven against a live project. Two kinds of lock, and the difference matters. The four Run and Evaluations routes are surfaces Pro has designed and not backed, so they are roadmap previews that say so and never imply that paying switches them on. The things worth paying for are locked in place on the live pages instead, over features Pro has actually shipped: the cost/billed/kept strip on Overview, the receipt on Call detail, cost and kept columns on Calls, the AI engineer on Agents, billing and compliance on Settings. Three collisions had to be solved to land Pro's stylesheet on this repo: - Both stylesheets defined --accent, --chart-1..4 and --radius-sm/md/lg on :root, and free's @theme inline maps them into Tailwind. agent-control-bar paints a muted mic with data-[state=off]:bg-accent, so Pro's #ff3344 would have rendered a muted microphone in brand red, reading as live. Pro's are namespaced --sv-*. - Pro disables Tailwind preflight and carries its own global reset. Free's twelve vendored components were authored against preflight, so preflight stays and Pro's reset is scoped under .sv-console. Unlayered rules beat @layer base, so the console still wins without turning anything off. - Pro imports Geist from fonts.googleapis.com. Geist was already a dependency here and imported nowhere, so it is bundled instead and a deployed console makes no third-party request. Also plumbs VITE_API_BASE_URL through the env examples, the Dockerfile and compose. Nothing but the token endpoint was called before this, so a deployed console would have silently talked to localhost.
Four changes the founder asked for. Sign-in is gone. The console opens straight onto the agent, which is the right trade on the machine you are running it on. The consequence is written down in Settings and it is real: everything this console reads, an anonymous visitor reads too, including transcripts once the call log is wired. The backend keeps its JWT user slice and its superuser flag, so gating it later is adding a dependency to the routes, not building auth. GET /agents opens with it, since it only reports the agent name and the provider names this repo already publishes in its source. The four ShipVoice Pro rail items are grey and inert. There is nothing behind them here, so a link would only lead somewhere whose purpose is to sell. No promotion anywhere else. The locked pages, the in-page locks over the money strip, the receipt, the cost columns and the AI engineer are all gone. What stayed is every factual line they were wrapped around, including the sentence that matters most: this starter has no compliance gate, it will dial whoever you point it at, and you are the caller of record. One Upgrade button in the topbar instead, on every page, booking a call. Also swaps the placeholder rail mark and the purple bolt favicon for the real ShipVoice logo, and carves the name and the mark out of the MIT grant. The code is theirs; the brand is not.
…e agent Settings was a second, worse copy of Agent detail. Its first two panels repeated that page almost row for row: name, business, dispatch name, prompt, then STT, LLM and TTS. Agent detail already owns all of it, renders it better, and in ShipVoice Pro it is per-agent, so anything agent-shaped on a deployment-wide page is wrong by construction. Both panels are gone, and the agent.py:63 owners went with them: a row naming a line number is wrong the next time somebody adds an import. What is left is genuinely deployment-wide, so the page is called Deployment. Nothing on it is settable and it says so; calling it Settings promised a control that was never there. /settings redirects. Three panels: where this console points, who can open it, and the compliance warning kept verbatim. The three-column table is replaced by the value-over- owner pattern Agent detail already uses, which is what actually fixes the overflow rather than hiding it behind a scrollbar. Adds GET /api/v1/deployment so the page can report the LiveKit project and the real registration state instead of saying it cannot read them. It serves no key and no secret, and a test asserts that by putting real-looking secrets in the config and grepping the response. Also fixes the frame. The rail is sticky at 100vh but the document scrolled, so on a short window the rail ran out partway down and the column below it fell back to the frame background. The shell now owns the viewport and the page body scrolls inside it, which is what a pinned footer strip requires. Verified at 844x460, the size that showed it: rail full height, footer pinned, nothing overflowing horizontally.
The console has no sign-in, so register, login, user CRUD, the JWT layer, the password hashing and the admin dependency were serving nobody. Deleting them takes eight source files and two test files out of the repo. The MCP surface goes with them. It mounted with include_tags=["mcp-tools"], and the only two endpoints carrying that tag were list_users and get_user, so with those gone it would have served a server exposing zero tools. src/api/mcps.py registered no routes at all and was already dead. That drops fastapi-mcp and pyjwt from the dependency list, and 519 lines from the lock. The backend is now four routes: health, token, agents, deployment. Docs corrected in the same pass, because both READMEs advertised the User slice and the MCP surface as features, and the manual quickstart told you to set a JWT secret that no longer exists. Note this does NOT remove the database requirement. LiveKit credentials are about to live there instead, so Postgres stays; it just stops being about accounts.
…sole The env is how you bootstrap a fresh clone. It seeds one row the first time the app starts against an empty table and is never read again, so a project changed in the console survives a restart, which is the whole reason to move it. The token service reads the credentials on every mint instead of capturing them at construction, so a change takes effect on the next call without restarting the backend. If the row is missing, and it is on first boot because the table does not exist until you migrate, it falls back to the environment rather than refusing to mint. Verified: source reads "environment" before the migration and "database" after, and tokens mint throughout. The secret is write-only. This backend has no authentication, so GET returns the url, the last four characters of the key, and whether a secret exists, and nothing that could be used as a credential. PUT is refused unless ENV=dev, for the same reason: an open write that repoints the LiveKit project would let anyone who can reach the port take over the calls. Both are tested. Saving with a blank secret keeps the stored one, because the console cannot read it back and still has to be able to change the url or the key. The page states the one thing this design cannot fix: the worker reads its own env and does not see any of it. Change the project here and you must change agent/.env and restart the worker, or tokens get signed for one project while the worker waits on another and calls connect to silence. Replaces the users init migration, which created a table for the auth slice this repo no longer has. A dev database from before this needs 'docker compose down -v'.
… about it The page used to carry a paragraph telling you that changing the project here would not reach the worker, and that you had to go edit agent/.env and restart it or every call would connect to silence. A warning you have to read and act on every time is a design that failed, especially for a failure this quiet: tokens signed for one project, the worker waiting on another, no error logged anywhere. So the worker takes its project from the backend now. At startup it fetches the credentials and writes them into its own environment before the LiveKit CLI reads it, then polls for a revision change and raises SIGTERM on itself when it sees one. LiveKit's own draining runs first, so a call in progress finishes, and the supervisor brings the worker back on the new project. Verified end to end, not reasoned about: changed the key through the API, watched the agent container restart itself nine seconds later, restored it, watched it register again, then placed a real browser call that the agent answered on credentials it had never seen in its environment. The full credentials need one endpoint that serves the secret, so that endpoint is the only one with a credential in front of it: a shared token both sides carry, failing closed when unset rather than turning the secret public. Refuses 401 without a token and 403 with a wrong one. The worker never hard-depends on this. An unreachable backend, a refused token or an unconfigured project all leave the environment untouched and the worker running on what it already had, because a console being down must not become every call failing. Four of the six new agent tests cover exactly those paths. The console asks before editing now, and says what actually happens: the worker restarts, calls in flight finish, calls arriving in that window are missed.
…orld Three fixes and a provider swap. The hard stop first. A fresh 'docker compose up' followed by the README ended with the first Start test call returning a raw UndefinedTableError in the browser, because nothing ran migrations and _row() raised before the env fallback under it could run. That fallback was dead code in exactly the case it was written for. It now catches SQLAlchemyError at both the query and the session, so an unmigrated or unreachable database falls back to the environment instead of 500ing, and the lifespan brings the schema to head on startup. Both the migration and the seed run under one Postgres advisory lock. Gunicorn starts several workers and every one of them was racing: alembic is not safe to run concurrently, and the seed's check-then-insert is not atomic, which is how a single-row table ended up holding two rows on this machine. The worker refuses to start on credentials that cannot work, and names the variable and the file. Before this it looped raw aiohttp 401 tracebacks forever under 'restart: unless-stopped' without ever printing LIVEKIT_URL, while the browser blamed an invalid API key for what was usually an unedited URL. env_is_complete() had been written and never called. The persona moves to agent/prompts/instructions.md, so describing an agent is a file write rather than a Python edit, and a change is a restart rather than a rebuild. Substitution is str.replace, not str.format: the file is user-editable and the first person to paste a JSON example into it would have hit KeyError. Providers: Deepgram nova-3 stays, the LLM becomes Cerebras gemma-4-31b and the TTS becomes Inworld inworld-tts-2. Verified against both APIs with real keys before wiring: Cerebras answered, Inworld returned 57,360 bytes of audio. The plugins need livekit-agents >= 1.5.9, so that is a patch bump from 1.5.6 rather than the jump to 1.6.9 the latest plugins would have forced. The drift test that pins these to agent.py was updated with them, and caught the change.
.env.example went from 21 keys to six, and the six are exactly the ones that need an account somewhere: three LiveKit, three providers. Everything else has a default in code or in compose. DB_* now default to the compose service, ENV defaults to dev on both services rather than prod (trimming the file used to silently promote the backend to production and 403 the console's own LiveKit editor), and AGENT_SERVICE_TOKEN ships empty. Empty matters. Both tokens shipped as the identical literal 'change-me-to-a-long-random-string', so they matched, and that literal was the only guard on the endpoint that returns the LiveKit secret in plaintext. Anyone who copied the example and exposed their backend published their LiveKit project. The code already treats empty as "endpoint disabled", so this is strictly safer than what was there. The first screen is Agents now. It was Overview, which called /api/v1/calls and took three 404s on load, because this backend has no call log and is not getting one. Overview, Calls and Call detail are deleted along with the four API functions behind them, and the rail says what is true: one section for what this starter does, one for what ShipVoice Pro adds. Calls moved into the Pro section, which is where it belongs: this repo records nothing about a call once it ends, so a Calls page here could only be an empty table pretending to be a feature. The footer followed. It was a live-events pulse over call turns; it now reports the LiveKit project and whether the backend answers, which are things this deployment actually knows. DottedLineChart, LiveEventsBar, LegendGrid and Meter went with it rather than sit in the repo as charting primitives with nothing to chart. Compose gained the defaults that let .env stay small, a read-only mount of agent/prompts so a persona edit is a restart not a rebuild, and a comment saying the two things that cost an hour each: it is the only env file compose reads, and VITE_ values are baked at build time so config changes need --build, never restart.
scripts/doctor.py is the piece that makes setup fast, not the interview. Every failure in this stack is quiet: a mismatched agent name mints a valid token, opens a real room, and produces no error anywhere, so the useful thing is not another prose file, it is a probe that says which of eight things is wrong and what to do about it. Config mode checks .env, the agent name on both sides, the LiveKit URL against the placeholder, and the service token. --live additionally probes each provider with a real request, separating a rejected key from a network problem so nobody rotates a key that was fine, then checks the compose services, the backend, and mints a token, which proves the database, the credentials and the signing in one round trip. Verified against all three live APIs. AGENTS.md carries the six facts that actually cost an hour, two of which were written down nowhere: compose reads only the root .env, and VITE_ values are baked at build time so a config change needs --build and never restart. CLAUDE.md is four lines and points at it rather than duplicating it, because a second copy is a second thing to drift. .claude/commands/setup.md asks one question and leans on the doctor for the rest. It never handles a key: it names the variable and the file and waits, because everything typed into a coding agent's conversation is persisted to a transcript on disk that the human does not know to check. That rule is in both the command and AGENTS.md. Docs updated for Cerebras and Inworld, and the README now leads with console mode as the fastest proof the loop works, since it needs no LiveKit, no backend and no database.
The fail-fast was gated on "not console mode", which caught 'download-files' too. That step prefetches the VAD and turn-detector models during the Docker build, where no credentials exist and none are needed, so 'docker compose up --build' died on the agent image. Gate it on the subcommands that actually connect instead, with a test that reads the gate so this cannot come back. Also fixes DECLARED_TTS, which still said cartesia after the provider swap, and the drift test that let it. That test only ever read agent.py, so it happily passed while the console reported a provider the agent does not use. It now also asserts each declared string names its own provider, which is the half that was missing.
An adversarial pass over the whole branch raised 20 findings; 18 survived verification. Most were mine. Two were credential exposure. backend/.env.example still shipped AGENT_SERVICE_TOKEN as the literal 'change-me-to-a-long-random-string' while the root file had been fixed to empty, and both READMEs tell you to copy that file verbatim. The reviewer booted the real app with it and got HTTP 200 back from /api/v1/internal/livekit with the LiveKit url, key and secret in full. Empty now, which the code already treats as "endpoint disabled". And the write gate turned out to be derived from ENV, which this branch had flipped from prod to dev for console ergonomics. That inverted an authorization decision from refuse to allow: the reviewer sent an unauthenticated PUT and actually repointed the running deployment at another LiveKit project. It is now gated on an explicit CONSOLE_WRITES_ENABLED, off in code, on in compose, and compose publishes on 127.0.0.1 rather than every interface. The third blocker was a claim, not code: the README said ShipVoice Pro's compliance "fails closed", which the founder's own decision record forbids publishing because the shipped gate runs after ctx.connect() and does not gate the dial. Rewritten to what exists. The rest, briefly. Running alembic in-process called fileConfig(), whose disable_existing_loggers default set disabled=True on every logger the app had just configured, silencing its own warnings for the life of the process; verified fixed by checking the flag before and after. The sync block sat at module scope, so LiveKit's spawned job processes each opened their own poller pulling the plaintext secret every 15 seconds, and none could act on a change because job processes ignore SIGTERM; it moved into the __main__ guard. A failed fetch at boot permanently disabled the watcher, silently, which compose made the default race; the watcher now starts anyway and adopts on the first successful poll, and the agent waits for a healthy backend. asyncpg raises a bare ConnectionRefusedError, not a SQLAlchemyError, so the env fallback never fired for the manual path it was written for. Three surfaces were lying. The Agents page rendered a "Calls 7d" column explained by a call log this backend does not have. The Deployment page promised the worker follows console edits, which is false unless a service token is set, and it ships empty; the backend now reports worker_follows and the copy is gated on it. Both pages still carried the deleted auth slice's "not an administrator" screen telling readers to UPDATE a users table that no longer exists. Also: doctor never checked LIVEKIT_API_KEY or LIVEKIT_API_SECRET and then said "Everything required is set"; agent/CLAUDE.md still described the pre-swap providers and denied the backend calls this branch added, so it is deleted in favour of the root AGENTS.md; the root .gitignore covered .env but not .env.*; dead Call* types pointed at a removed API; and the README now says where the LiveKit signing secret actually lives once you save a project, which is unencrypted in Postgres and therefore in the volume and any dump.
|
Important Review skippedToo many files! This PR contains 133 files, which is 33 over the limit of 100. To get a review, reduce the PR to 100 files or fewer by splitting it into smaller PRs or changing its base branch. Upgrade to a paid plan to raise the limit. This review couldn't start because sufficient usage credits or metered capacity aren't available. Add credits or update usage-based reviews in the billing tab, then retry. ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: ⛔ Files ignored due to path filters (5)
📒 Files selected for processing (133)
You can disable this status message by setting the Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Restores the seven tabs from reference/shipvoice-overview.html rather than the two I had cut it down to. Watch (Overview, Calls), Run (Campaigns, Channels, Customers), Build (Agents, Evaluations), Settings in the rail footer. Overview, Calls and Agents work. The other four are grey and not clickable, with no badge: the reference has none there, and a label would be selling rather than describing. Making Overview and Calls real meant building what they read, so this also adds the call log the repo did not have: Call and Turn tables, a service whose start is idempotent on room_name so a worker restart neither 500s nor duplicates, the four read routes the console already expected including the trailing slash that avoids a 307, and three ingestion routes behind the same shared service token as the LiveKit one. No cost field anywhere, guarded by a test that matches on substrings rather than exact names, because the field that shows up is not called cost, it is called estimated_cost_usd. The worker reports its own calls now, opt in and off by default, queued off the audio path, and every network call swallowed: a voice call must not fail because a log write failed. Two things matched back to the reference that my port had drifted from. The face is Space Grotesk, not Geist, and the accent ramp takes the reference's exact values, so --brand-400 is #e5323f rather than #ff3344. Everything else already agreed: same token names, same 4px grid, same 240/56/48 frame. Verified from an empty volume: four tables self-create, a browser call is answered, and it lands in the log as a completed call of 25 seconds with its transcript, which the summary then rolls up. Fixed one thing that showed up only with real data: a single tick made the live-events bar a solid red slab across the whole footer, because the bars were flex rather than fixed width. reference/ is gitignored, the way the paid repo treats its exports/. It is design source and half a megabyte of bundled markup nobody cloning this needs.
Rebuilt both pages against reference/shipvoice-calls.png and shipvoice-call-detail.png, reading the real DOM out of the bundles rather than eyeballing the screenshots. The rule everywhere: keep the reference's layout and every cell, fill what this repo can answer, and render a dash on the rest. So the Calls table keeps all fourteen columns in the reference's order, with time, agent, channel, to, duration and status carrying real data, and config, campaign, dir, cost, billed and kept dashed. The design's own annotation already states the principle: a dash means unmeasured, never zero. That is also why there are no Pro badges or lock icons on either page. A dashed column says what is missing without selling anything, and this console has exactly one upsell, in the topbar. Call detail keeps the seven-cell strip in the reference's order. The values are dashes, but the hints under STT, LLM and TTS are real, read from /api/v1/agents, so the strip shows the actual voice stack with unmeasured money rather than an invented figure. Transcript on the left with elapsed timestamps, and the three right-hand panels: Compliance says plainly there is no gate here, Stack is real, Quality is dashed because nothing measures latency. Controls that cannot do anything are rendered disabled with a title saying why, rather than dropped, so the row still matches. Copy as .md is the exception: that one is implementable from the transcript, so it is implemented. Two fixes found by looking at it with real rows in the table. The channel filter was a native select, which keeps its own chrome regardless of the background it is given and rendered as a white form field beside the dark buttons. And a drawer reset its state synchronously inside an effect, which React 19's lint rejects and which was redundant anyway, since the drawer is already keyed by call id and remounts per row.
The reference labels a call still running "In progress"; three pages said "Active". Changed in all three at once, because the badge, the filter chip and the detail header have to agree or the filter looks broken. Agents and Agent detail keep "Active": that is about the agent being the one this deployment runs, which is a different thing.
Three screens, matched to reference/shipvoice-agents.png,
shipvoice-agent-detail.png and shipvoice-agent-test-call.png.
Agents list: the eleven-column table, a recharts donut of where calls
land, and a "Scores over time" panel carrying a placeholder chip. Nothing
in this repo scores an agent, so the chart is an illustrative shape and
says so twice: on the chip and under the axis. Columns with no
measurement behind them stay dashed rather than printing a zero.
Agent detail: the call flow on an @xyflow/react canvas, read-only. Two
patterns only, sequential and supervisor, which is what the worker can
actually run. Structure comes from the repo, not from traffic, so no node
carries a call count.
Test call: the LiveKit agents-ui wave visualizer on its own route, fed
the agent's track so it draws what you are hearing. The reference's
footer actions save an eval scenario and apply a staged config; this repo
has neither, and a disabled button for a feature that does not exist
reads as broken rather than absent, so they are gone. The greyed
Evaluations rail item is where that absence is stated.
Backend: GET /api/v1/calls/rollup?days=7 for the donut, declared before
/{call_id}. Null agent names group under "unknown", by_agent and
by_channel each sum to total, and an empty log returns empty arrays
rather than null. AgentSummary gains pattern, set by AGENT_PATTERN, which
folds case and falls back to sequential on anything unrecognised so an
unknown value cannot reach the console.
DottedLineChart picked its tick format per value, which printed a percent
axis as 91 / 46 / 0.00. It now decides once from the top of the axis.
The Providers table showed the prompt path as dead text. It is now the way
in to a full-screen dialog holding the file itself.
The file stays the source of truth. No database, no revision to poll, no
change to the worker. Assistant() is built inside entrypoint(), which
LiveKit runs per job, and its constructor calls load_instructions(), which
reads the file every time. So a save is picked up by the next call with
nothing restarted, and hand-editing the file in an editor keeps working
exactly as before. The backend mounts the same directory the agent reads,
read-write; the agent's mount stays read-only, because one writer is what
lets the save be atomic.
Backend: GET and PUT /api/v1/agents/{slug}/prompt. The write renames a
sibling temp file over the target rather than truncating it, since the
worker re-reads this file on every job and the window for reading a
half-written persona is every call that starts during the write. Writes
are gated on CONSOLE_WRITES_ENABLED, capped at 100 KB measured on UTF-8
bytes, 404 on an unknown slug, and 409 naming the path and the mount when
the write cannot land. A missing file reads as exists=false, never a 404:
the agent falls back to its packaged default and the console has to be
able to say so.
Nothing about the prompt is rejected. It is prose, and the worker
substitutes with str.replace, so braces and a missing {agent_name} are
both legal. The response carries non-blocking warnings instead: no
placeholder, an empty file (which does not restore the packaged default,
it runs the agent with no guardrails at all), and a file that is not UTF-8
or is UTF-16, which the agent cannot read either.
Fixes found by review before this shipped:
- Saving while still typing destroyed the later keystrokes. The response
was adopted as the textarea value unconditionally, which wipes text with
no undo entry, and the success banner then covered content that never
left the browser. The response is only taken as the new baseline when
nothing was typed while the PUT was in flight.
- The focus trap was bound to the dialog's own onKeyDown, so it never ran
once focus fell to body, which happens on a scrim press and on
dismissing the close-confirm. Tab then walked into the rail behind the
scrim, where one Enter routed away and took the unsaved prompt with it.
It is on the window now, where Escape already was.
- The scroll lock targeted document.body. The scroller is .bd, so the page
kept moving under the dialog.
- editable was decided by the persona file's own write bit, but the write
is rename-based and only needs the directory. That greyed out the editor
on deployments where saving works, and promised protection to anyone who
chmod 444'd the file while a PUT went straight past it.
- A failed GET left the dialog reading "loading" forever under a red alert
with no way to retry.
- A prompt that was not UTF-8 escaped as a 500, because UnicodeDecodeError
is a ValueError and only OSError was caught.
Two tests were asserting the wrong thing and passed while the browser
failed: the scroll-lock test certified an assignment on an element that
never scrolls, and the focus-trap test dispatched its keydown on the
dialog, so the handler ran regardless of where focus was.
Known limitation: the PUT is a blind overwrite. Two console tabs, or a
hand edit made while the dialog is open, resolve last-write-wins with no
warning.
The same sentence was repeated as a footnote under five tables. Read twice it is a rule; read five times it is furniture, and it pushed the thing it explained further off the screen each time. It is one control in the top bar now, which every page renders: a "?" that opens the explanation on hover and on keyboard focus. Hover alone would make it decoration, so the button is focusable and the tooltip is its aria-describedby target. It is hidden with visibility and opacity rather than display, because a tooltip removed from the tree cannot be resolved by a screen reader. Removed from Overview, Calls, Call detail and Agents. The annotations that explain something specific to their own page stay: where the flow canvas gets its structure, why providers are read-only, and what the Deployment page does with the LiveKit secret. Those are not the same sentence five times.
The Overview had been cut down to the parts this repo can measure, which
hid what the paid console actually does. Every section of the reference is
back: the cost to billed to kept strip with the Stripe portal, all five
stat cells, the nine-column Recent calls table, Running now, Health, and
Spend per day.
What this repo measures is its own: metered minutes, calls today, the call
rows, and the two Health rows that read the agent-to-backend seam. What it
does not measure carries the reference's own figures behind a 'sample'
chip, the idiom the Agents page already uses for its placeholder chart, so
nobody reads them as their deployment's numbers. The cost, kept and config
columns of a real call stay dashed rather than sampled: printing a sample
cost against a call that actually happened would be a lie about that call.
Every paid control is rendered and inert. Export .csv, Open Stripe portal,
Pause and Open are real disabled buttons with a title saying what is
missing, and the date range is a bordered field the way the reference
draws it. No hrefs, no onClick, no promotional line.
Backend: GET /api/v1/calls/overview for the figures that were missing,
declared before /{call_id}. One query, so the failure count and the window
it is a rate over cannot straddle a call landing between two reads.
Fixed before this shipped, from review:
- The Health seam age rendered the integer the backend computed and never
recomputed it, so a page left open kept asserting '12s ago' an hour
after the agent went quiet. That is the one claim the row exists to make.
It counts forward from the timestamp on a tick now.
- 'N in range' was the all-time total printed under a greyed 'Last 30 days'
control, so the page claimed a filter it does not apply.
- A zero-failure deployment wore the same solid red chip a real outage
gets, and an empty window was labelled 'of an unknown total' when zero
calls in a window is measured, not unknown.
- 'active' counted every open call ever recorded. A worker killed mid-call
never sends a finish report, so that row would have been counted as in
flight for the life of the deployment. It is bounded to the same window
as the failure rate.
- Metered minutes rounded to whole minutes, turning a real 0.4 into '0' on
the one cell that proves the seam works.
- Three per-panel paragraphs restated what the sample chip already says,
reinstating the footnote pattern deleted in the previous commit. The
explanation rides the chip now.
- A Refresh button that the reference does not have, and the only tooltip
on the page that named the paid product rather than saying what is not
built here.
Nine 'sample' chips shared the Overview, and nine repetitions of the same word read as clutter rather than as a caveat, which is the point at which people stop seeing it. A 6px accent dot beside the figure instead. It says the same thing on hover, and to a screen reader through role="img" and an aria-label, since the dot carries no text of its own and the title is otherwise the only thing explaining it.
Turns this starter into something a stranger can clone, set up in minutes, and talk to, and makes it a credible funnel for ShipVoice Pro rather than one that contradicts it.
The two goals
A fresh clone talks.
docker compose up --buildfrom an empty volume now produces a browser call the agent answers. Before this, the first Start test call returned a rawUndefinedTableError: nothing ran migrations, and the env fallback in the credentials service was unreachable because the query raised before it. Both fixed, plus the migration and the seed now run under one advisory lock (gunicorn's workers were racing, which is how a single-row table ended up with two rows).A coding agent can set it up.
scripts/doctor.pynames which of eight things is wrong and how to fix it, because every failure here is quiet: a mismatched agent name mints a valid token, opens a real room, and errors nowhere.AGENTS.mdcarries the six facts that cost an hour each, two of which were written down nowhere: compose reads only the root.env, andVITE_*are baked at build time so a config change needs--build, neverrestart..claude/commands/setup.mdasks one question and leans on the doctor.Providers
Deepgram
nova-3stays. LLM moves to Cerebrasgemma-4-31b, TTS to Inworldinworld-tts-2. Both verified against the live APIs before wiring. The plugins needlivekit-agents >= 1.5.9, a patch bump from 1.5.6.The console
Ported from ShipVoice Pro, then stripped of promotion: no locked pages, no in-page upsells, one Upgrade button that books a call. The rail is one live section and one greyed ShipVoice Pro section. Calls sits under Pro because this repo records nothing once a call ends, so a Calls page here could only be an empty table pretending to be a feature.
The auth slice is gone (users, JWT, hashing, admin gate, and the MCP surface that had no tools left), which is ~1,300 lines. LiveKit credentials moved into Postgres, seeded once from env, editable from the console, and the worker follows them and restarts itself when they change.
Review
An adversarial pass raised 20 findings; 18 survived verification and are fixed in
c1cbbe2. Three were blockers:backend/.env.exampleshipped a working service token that returns the LiveKit secret in full. Reproduced with a real request against the real app. Empty now.ENV, which this branch flipped to dev, inverting an authorization decision from refuse to allow. The reviewer repointed the running deployment with an unauthenticatedPUT. Now an explicitCONSOLE_WRITES_ENABLED, off in code, and compose publishes on127.0.0.1.Verification
down -vthenup --build: schema self-creates, one seed row,doctor --liveall green, browser call answered in 3sKnown gaps
The worker only follows console edits when
AGENT_SERVICE_TOKENis set, which ships empty; the page now says so rather than claiming otherwise. And app-levelINFOlogs sit below the configuredWARNINGlevel, so the startup confirmations do not print, though the operator-facing warnings do.