Skip to content

The console, the onboarding, and a fresh clone that talks - #4

Merged
mahimairaja merged 28 commits into
mainfrom
feat/free-console-and-onboarding
Aug 9, 2026
Merged

The console, the onboarding, and a fresh clone that talks#4
mahimairaja merged 28 commits into
mainfrom
feat/free-console-and-onboarding

Conversation

@mahimairaja

Copy link
Copy Markdown
Member

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 --build from an empty volume now produces a browser call the agent answers. Before this, the first Start test call returned a raw UndefinedTableError: 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.py names 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.md carries the six facts that cost an hour each, two of which were written down nowhere: compose reads only the root .env, and VITE_* are baked at build time so a config change needs --build, never restart. .claude/commands/setup.md asks one question and leans on the doctor.

Providers

Deepgram nova-3 stays. LLM moves to Cerebras gemma-4-31b, TTS to Inworld inworld-tts-2. Both verified against the live APIs before wiring. The plugins need livekit-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.example shipped a working service token that returns the LiveKit secret in full. Reproduced with a real request against the real app. Empty now.
  • The write gate was derived from ENV, which this branch flipped to dev, inverting an authorization decision from refuse to allow. The reviewer repointed the running deployment with an unauthenticated PUT. Now an explicit CONSOLE_WRITES_ENABLED, off in code, and compose publishes on 127.0.0.1.
  • The README claimed Pro's compliance "fails closed", which the shipped gate does not do.

Verification

  • Fresh down -v then up --build: schema self-creates, one seed row, doctor --live all green, browser call answered in 3s
  • 77 tests: agent 28, backend 40, frontend 9. ruff, mypy and eslint clean
  • Old published token now returns 403; ports bound to loopback

Known gaps

The worker only follows console edits when AGENT_SERVICE_TOKEN is set, which ships empty; the page now says so rather than claiming otherwise. And app-level INFO logs sit below the configured WARNING level, so the startup confirmations do not print, though the operator-facing warnings do.

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.
@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Important

Review skipped

Too 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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 37132e79-cb2a-4c03-b2c9-86095bf37c61

📥 Commits

Reviewing files that changed from the base of the PR and between 5edb770 and 26d637d.

⛔ Files ignored due to path filters (5)
  • agent/uv.lock is excluded by !**/*.lock
  • backend/uv.lock is excluded by !**/*.lock
  • frontend/pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
  • frontend/public/favicon.svg is excluded by !**/*.svg
  • frontend/public/logo-boat.svg is excluded by !**/*.svg
📒 Files selected for processing (133)
  • .claude/commands/setup.md
  • .env.example
  • .gitignore
  • AGENTS.md
  • CLAUDE.md
  • LICENSE
  • README.md
  • THIRD_PARTY_NOTICES.md
  • agent/.env.example
  • agent/.gitignore
  • agent/CLAUDE.md
  • agent/README.md
  • agent/main.py
  • agent/prompts/instructions.md
  • agent/pyproject.toml
  • agent/src/agent.py
  • agent/src/agents/assistant.py
  • agent/src/core/config.py
  • agent/src/core/events.py
  • agent/src/core/livekit_sync.py
  • agent/src/prompts/instructions.py
  • agent/src/services/call_reporter.py
  • agent/tests/test_call_reporter.py
  • agent/tests/test_instructions.py
  • agent/tests/test_livekit_sync.py
  • agent/tests/unit/test_assistant.py
  • agent/tests/unit/test_events.py
  • backend/.env.example
  • backend/DEPLOY.md
  • backend/Dockerfile
  • backend/README.md
  • backend/migrations/env.py
  • backend/migrations/versions/0001_livekit_settings.py
  • backend/migrations/versions/0002_calls.py
  • backend/migrations/versions/ebc349384d1e_init_migration.py
  • backend/pyproject.toml
  • backend/src/api/endpoints/agents.py
  • backend/src/api/endpoints/calls.py
  • backend/src/api/endpoints/deployment.py
  • backend/src/api/endpoints/internal_calls.py
  • backend/src/api/endpoints/internal_livekit.py
  • backend/src/api/endpoints/livekit.py
  • backend/src/api/endpoints/token.py
  • backend/src/api/endpoints/users.py
  • backend/src/api/mcps.py
  • backend/src/api/routes.py
  • backend/src/backend/__init__.py
  • backend/src/core/config.py
  • backend/src/core/container.py
  • backend/src/core/events.py
  • backend/src/core/security.py
  • backend/src/main.py
  • backend/src/models/__init__.py
  • backend/src/models/calls_model.py
  • backend/src/models/livekit_model.py
  • backend/src/models/users_model.py
  • backend/src/repository/calls_repository.py
  • backend/src/repository/users_repository.py
  • backend/src/schemas/agents_schemas.py
  • backend/src/schemas/calls_schemas.py
  • backend/src/schemas/deployment_schemas.py
  • backend/src/schemas/livekit_schemas.py
  • backend/src/schemas/users_schemas.py
  • backend/src/services/agent_prompt_service.py
  • backend/src/services/calls_service.py
  • backend/src/services/livekit_settings_service.py
  • backend/src/services/token_service.py
  • backend/src/services/users_service.py
  • backend/tests/conftest.py
  • backend/tests/unit/test_agent_prompt.py
  • backend/tests/unit/test_agents_endpoint.py
  • backend/tests/unit/test_calls_endpoints.py
  • backend/tests/unit/test_calls_service.py
  • backend/tests/unit/test_config.py
  • backend/tests/unit/test_deployment_endpoint.py
  • backend/tests/unit/test_livekit_settings.py
  • backend/tests/unit/test_security.py
  • backend/tests/unit/test_startup_and_fallback.py
  • backend/tests/unit/test_token_endpoint.py
  • backend/tests/unit/test_token_service.py
  • docker-compose.yml
  • frontend/.env.example
  • frontend/.gitignore
  • frontend/Dockerfile
  • frontend/eslint.config.js
  • frontend/index.html
  • frontend/package.json
  • frontend/src/App.css
  • frontend/src/App.tsx
  • frontend/src/api.ts
  • frontend/src/components/AppShell.test.tsx
  • frontend/src/components/AppShell.tsx
  • frontend/src/components/PromptDialog.test.tsx
  • frontend/src/components/PromptDialog.tsx
  • frontend/src/components/Rail.tsx
  • frontend/src/components/TestCall.tsx
  • frontend/src/components/agents-ui/agent-audio-visualizer-wave.tsx
  • frontend/src/components/agents-ui/react-shader-toy.tsx
  • frontend/src/components/ai-elements/conversation.tsx
  • frontend/src/components/ai-elements/message.tsx
  • frontend/src/components/app/session-view.tsx
  • frontend/src/components/app/welcome.test.tsx
  • frontend/src/components/app/welcome.tsx
  • frontend/src/components/console.test.tsx
  • frontend/src/components/ds.tsx
  • frontend/src/components/ui/button-group.tsx
  • frontend/src/components/ui/button.tsx
  • frontend/src/components/ui/input.tsx
  • frontend/src/components/ui/select.tsx
  • frontend/src/components/ui/separator.tsx
  • frontend/src/components/ui/toggle.tsx
  • frontend/src/components/ui/tooltip.tsx
  • frontend/src/console.css
  • frontend/src/hooks/agents-ui/use-agent-audio-visualizer-bar.ts
  • frontend/src/hooks/agents-ui/use-agent-audio-visualizer-wave.ts
  • frontend/src/hooks/agents-ui/use-agent-control-bar.ts
  • frontend/src/index.css
  • frontend/src/lib/format.ts
  • frontend/src/lib/token-source.ts
  • frontend/src/lib/utils.ts
  • frontend/src/main.tsx
  • frontend/src/pages/AgentDetail.tsx
  • frontend/src/pages/Agents.tsx
  • frontend/src/pages/CallDetail.tsx
  • frontend/src/pages/CallLogs.tsx
  • frontend/src/pages/Deployment.tsx
  • frontend/src/pages/Overview.test.tsx
  • frontend/src/pages/Overview.tsx
  • frontend/src/types.ts
  • frontend/tsconfig.app.json
  • frontend/tsconfig.json
  • frontend/tsconfig.node.json
  • scripts/doctor.py

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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.
@mahimairaja
mahimairaja merged commit 3b69261 into main Aug 9, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant