a·v·r·i·z — Engineering Notes
How avriz.io is built, and why. A living document: architecture, the decisions behind it, and how the pieces wire together.
- What it is
- Technology stack
- Topology
- Routing: one reverse proxy, memorable URLs
- Scale to zero
- The agent: Goose behind the voice
- The metered LLM proxy and the grader
- The learning flywheel: a platform that learns from its own traffic
- Memory storage
- Voice
- Apphost: shipping user apps
- Provisioning
- Deploy pipeline
- Security decisions, collected
- Horizontal scaling: the apphost front door
- The git-aware file manager
- Giving the agent a browser
- Project knowledge you can bring
- Many chats, each with its own memory
- How the changelog ships itself
- Vision: from a screenshot to a spec
What it is
a·v·r·i·z is a voice-native agentic IDE. Instead of a laptop and an editor, each user gets a private Linux machine in the cloud with a coding agent on it. You describe what you want — out loud, from your phone — and the agent writes the code, runs it, tests it, deploys it, and tells you when it's live.
The whole product is three long-lived services plus one machine per user:
| Piece | Role |
|---|---|
Control plane control-plane/main.py | The brain: auth, billing, routing, the metered LLM proxy + grader, memory, provisioning. FastAPI. |
Box app box-app/app.py | Runs on every user's box: the agent, terminal, file manager, voice, editor. One per user. FastAPI. |
Apphost apphost/agent.py | A tiny PaaS that builds and serves the apps users ship, at *.avriz.app. |
| Tenant box | An EC2 instance, one per user. Amazon Linux 2023. Scales to zero when idle. |
The guiding principle: one isolated machine per person. No shared sandbox, no shared model keys across tenants, nothing you build training someone else's model. Most of the harder design decisions fall out of taking that isolation seriously.
Technology stack
The stack is intentionally boring where reliability matters: AWS for the substrate, Python 3 + FastAPI for the control services, Caddy for the public edge, Stripe for billing, and static HTML/CSS/vanilla JavaScript for the product UI. The interesting part is how it composes around one private machine per user.
| Layer | What we use | Why it is there |
|---|---|---|
| Cloud | AWS, primarily EC2, IAM, SSM, S3, Secrets Manager, and DynamoDB. | EC2 gives every user a real Linux box. IAM keeps the control plane powerful and tenant boxes deliberately constrained. SSM lets GitHub Actions deploy without opening SSH. S3 carries deploy artifacts. Secrets Manager keeps runtime secrets out of git. DynamoDB stores durable per-tenant memory. |
| Compute | EC2 control-plane host, apphost node(s), and one EC2 tenant box per user. | The control plane is always on; tenant boxes scale to zero when idle and wake on demand. Apphost runs user-shipped apps behind *.avriz.app. |
| Edge / proxy | Caddy with on-demand TLS and reverse-proxy rules. | Caddy terminates TLS for avriz.io, *.t.avriz.io, and *.avriz.app, then reverse-proxies to the Python control plane on 127.0.0.1:8091. The control plane performs the second hop to the correct tenant box or apphost placement. |
| Backend | Python 3, FastAPI, Uvicorn, httpx, boto3, stripe-python. | FastAPI serves the website, dashboard APIs, auth, billing, provisioning, memory APIs, the managed-LLM proxy, and the tenant/apphost routers. Uvicorn runs under systemd, so deploys are simple: sync code, install requirements, restart the service. |
| Billing | Stripe Checkout for one-off credit purchases, plus Billing Portal and webhooks (subscriptions survive for legacy accounts). | Everyone starts free; growth is buying credits à la carte, so Stripe mostly owns credit checkout and the webhook that lands the balance. The control plane mirrors customer ids and enforces the usage gates that meter against allowance, then credits. |
| Frontend | Server-rendered/static HTML, CSS, and vanilla JavaScript in control-plane/static/. | The UI is intentionally lightweight: landing, signup/login, dashboard, admin, docs, and this /eng page are static assets served by FastAPI. JavaScript calls the control-plane APIs directly; there is no heavy SPA framework in the critical path. |
| Deploy | GitHub Actions → S3 artifacts → AWS SSM. | Pushes to main package the changed service, upload an artifact, run an SSM command on the target EC2 instance, reinstall dependencies, regenerate environment files from AWS secrets, and restart systemd. Tenant box deploys roll to currently running boxes; stopped boxes pick up the refreshed template when rebuilt. |
Small surface area: Caddy owns public HTTP and certificates, Python the product logic, AWS the machines, Stripe the money.
Topology
Everything is in one AWS region. The control plane is the only component with broad IAM; boxes are deliberately powerless (see Security).
Routing: one reverse proxy, memorable URLs
Every box is reachable at <name>.t.avriz.io. The control plane fronts all these subdomains and proxies each request to the right box.
The subdomain label started as an 8-character hex id equal to the internal tenant id — workable, but not memorable. So we split the two concepts:
- tenant — a stable internal id that never changes. It keys everything durable: the routes table, the box's credentials, the deploy token (an HMAC of the tenant), the DynamoDB memory partition.
- slug — a memorable
adjective-noun-nounlabel (e.g.brave-otter-cove) that is only a URL alias. New boxes get one at provision time; existing boxes can regenerate from the dashboard.
The proxy resolves an incoming subdomain by slug OR tenant, so both the friendly URL and any legacy hex URL keep working.
Because the slug is decoupled from the tenant, renaming a box never re-keys anything — memory, tokens, and DynamoDB all key on the immutable tenant. That is why "get a new name" is instant rather than a migration.
TLS is issued on demand: the edge asks the control plane "should I get a certificate for this?" and cp answers yes only if that slug/tenant exists in the routes table. Guessable names can't be used to exhaust certificate issuance.
Scale to zero
An always-on VM per user would be expensive and mostly idle. So boxes scale to zero:
- A reaper in the control plane stops any box whose last activity is older than ~15 minutes.
- Visiting a stopped box shows a "your box is asleep" page with a Wake button; clicking it starts the instance (~20s) and reconnects.
- Everyone starts free with a monthly pool of active hours (25 on the free tier) — you burn time only while the box runs, so an idle project costs nothing. Past the pool the box rests until the month resets, or credits wake it.
A rule learned the hard way: only the explicit Wake action starts a stopped box. Earlier any mutating request could wake it, so a stray poll from a cached tab woke the box before the user asked.
Now a sleeping box answers navigations with the wake page and everything else with a 503 — nothing but the button spends money.
The agent: Goose behind the voice
The box runs the open-source Goose agent from Block, embedded as a first-class engine with its MCP tools, recipes, and skills.
How the home-grown engine gave way to Goose
The product started with a home-grown tool-loop ("Classic") that drove an LLM through read/write/run/git tools. Goose shipped alongside it as an opt-in, became the default, and has replaced Classic entirely — one engine, less surface area. Voice, durable memory, deploys, and PRs all carried over.
Wiring Goose in: ACP over a persistent session
Goose speaks ACP (Agent Client Protocol) over a WebSocket. On the box, goose serve runs as a systemd service (goose-acp) and the box app bridges the browser's Server-Sent-Events stream to it:
Each turn maps ACP session/update notifications (tool_call, agent_message_chunk, usage_update) into SSE events the UI renders. Tool permissions are auto-approved (GOOSE_MODE=auto) so a headless voice session never blocks on a prompt.
Two layers of memory, and why both
"Remembering" is two different problems.
- Conversation context — does the agent still know what we were talking about? Goose keeps its session on the box's EBS volume, which survives stop/start, so we persist the session id and
session/loadit on reconnect. Waking a box (or a deploy that restarts the app) resumes the exact conversation. - The visible chat window — does reopening the tab show the prior messages? Separate problem. Each turn is also appended to a durable transcript in DynamoDB (see Memory), and the UI repaints it on load.
On top sits durable long-term memory: facts the agent chooses to remember (remember/recall/forget). For Goose this is a dependency-free stdio MCP server on the box that calls the control plane's memory API — cross-box, survives-rebuild memory without handing the box any database credentials.
The engine is a seam, not a hard-wire
Goose is the default, not the only option. The box talks to its agent through one small interface — box-app/engines/base.py's AgentEngine — so the coding loop is swappable behind a stable contract (start a turn, stream events, load a session, cancel). get_engine() picks the implementation from config; today that's goose.py or opencode.py.
A user can switch their box to OpenCode from Settings → Agent & models: it re-seeds the engine and starts a fresh chat in about a minute, with files, memory and keys untouched. Everything else on this page — the metered proxy, the grader, voice, memory, deploys — sits above the seam and doesn't care which engine is behind it.
The metered LLM proxy and the grader
We sell a managed model option: the user brings no key, we route their calls and meter them. Two hard constraints:
- Managed keys must never reach the box. A box is the user's machine with a root shell — anything on it is readable. Keys stay on the control plane.
- Cheap work must not pay frontier prices. Most agent turns are trivial (read a file, run a command).
The path:
The box always sends a placeholder model name; the control plane's grader classifies each turn's difficulty with a fast model and routes to the cheapest capable tier on an auto ladder. Users can pin a model instead.
fugu / fugu-ultra tiers interleave on the same auto ladder between these Claude tiers.Two economics safeguards live here:
- Native prompt caching. Managed Anthropic traffic is translated to the native Messages API with
cache_controlon the conversation prefix. Gemini caches its prefix implicitly and reports the hit ascachedContentTokenCount, so cached tokens meter the same way across providers. - A spend gate. With the bundled allowance exhausted and no credits, the proxy returns
402and the agent stops — a runaway loop can't run up an unbounded bill.
The grader in detail: one graded turn, memoized
A "turn" is a distinct latest user message, classified once: a 16-token judge call on Gemini Flash that must answer with exactly one of trivial / simple / moderate / hard / expert.
Each grade maps to a rung on the auto ladder: trivial→gemini, simple→fugu, moderate→sonnet, hard→opus, expert→fugu-ultra. A per-tenant pin (or admin default) short-circuits the grader entirely.
If the judge call fails or times out (4s budget), we fall back to the cheapest rung rather than the most expensive — a grading outage makes us lose margin, never overcharge the user.
Two robustness details earned the hard way
Gemini Flash spends its whole 16-token budget on hidden "thoughts" and returns no word unless we pass reasoning_effort: none — without it every grade silently failed and mis-routed everything to the cheapest tier.
And if the routed upstream is out of money, the proxy climbs to the next funded rung instead of handing the agent an empty turn.
How often we actually grade: once per turn, not per call
_resolve_model checks, in order: a per-tenant model pin (skips grading entirely) → an in-memory per-tenant memo keyed on sha256(last_user_text)[:16] → only then a fresh judge call. So a message that triggers a fifty-step tool loop is graded exactly once, and a genuinely new message always grades fresh.
Which way the 10-minute memo TTL moves cost
The TTL only matters when a tool loop runs longer than the memo window and would otherwise re-grade the identical text mid-turn. Raising it means fewer redundant re-grades (cheaper); lowering it makes long turns more likely to re-grade before they finish (more judge calls, not fewer).
It is a cache-freshness knob, not a billing-interval knob: it doesn't change how often new messages get graded.
The judge prompt: forcing one word out of a chat model
The judge call is tiny: a system prompt ("You rate how hard a coding-agent request is. Reply with EXACTLY one word: trivial / simple / moderate / hard / expert") plus the raw last-user-message text (truncated to 4000 chars) and a max_tokens: 16 cap. No tool schema, no conversation history.
Parsing one word out of a chat model, and which model judges
Parsing is loose rather than exact-match: substring-search the lowercased response for each tier name in order, and if that fails, check whether the model echoed back a raw model id ("sonnet" instead of "moderate") and map it directly.
Judge preference is gemini first, then haiku — Gemini Flash is roughly 3× cheaper per token, so grading barely dents the margin it protects. Haiku is the fallback if the Gemini account isn't configured.
Two different "give up" paths: unconfigured vs. out-of-money
Two failure modes for a chosen model, degrading differently on purpose:
- Not configured (no API key for that tier) resolves via
_avail_or_up: walk up the easy→hard ladder until something with a key is found. The intent is "closest available capability," so a missingfugutier routes near "simple," not to the frontier. - Out of money at request time (Anthropic
402, Sakana429 usage_limit_reached) resolves via_fallback_order: ignore the ladder, sort every configured model by wholesale rate (input + output $/token), try cheapest-first, and skip any tier sharing the exhausted account — if the Anthropic key is dead, every Claude tier is skipped in one shot. Running out of credit is a billing event, not a capability gap.
Both paths funnel through the same non-streaming completion loop: try the graded model, and on an exhaustion signal keep walking the cheapest-first list (marking that account "dead") until one answers. Only then does the caller see an error.
Metering: the box reports tokens, the control plane prices them
The box can't know — or honestly report — which model served a call, because the routing decision lives only on cp. So after each call the box posts raw token counts (in, out, cached) to /internal/usage with its tenant deploy token, and cp decides what those tokens cost.
cp resolves the served model, prices cached input at the lower rate, and folds the counts into a per-tenant, per-month usage row (llm_in / llm_out / llm_cached / voice_chars / compute_s). The call is also stitched onto the routing ledger row, giving the admin Models tab a per-call trail of model · why-it-was-chosen · tokens · cost — metadata only.
Where the numbers come from
Token counts are the upstream's own accounting, not estimates. The box's local /v1 proxy reads the provider's usage block off the completion (prompt_tokens, completion_tokens, prompt_tokens_details.cached_tokens) and fires a fire-and-forget POST /internal/usage in a background thread, so metering never sits in the response path.
Voice meters the same way: /api/tts reports the character count actually synthesized (after code blocks are stripped for speech), keyed kind=voice. A failed report is dropped rather than retried — a lost count is a rounding error.
Which model gets billed: the routing-row join
cp reconstructs the served model at metering time. The proxy writes a routing-ledger row the instant it forwards a call — tenant, chosen model, and why — but with zero tokens, because those aren't known yet.
When the box's usage report lands, cp attaches it to the newest zero-token routing row for that tenant within a 900-second window and prices that row's model. The join keeps the price exact when the served model differs from the grader's verdict — a cheaper-funded fallback, or a utility-pinned classification call, is billed at what actually ran.
The why values, and what happens if no row is pending
A row's reason is one of: grader verdict, tenant pin, admin default, cheaper-fallback after an exhausted account, cheapest-rung utility pin, or a mid-turn escalation.
If no pending row exists (say cp restarted between forward and report), cp falls back to a best-effort resolution (tenant pin → that turn's grader verdict → account default) and writes a standalone row.
Cached input is real money saved
Prompt caching isn't just a latency win — it changes the bill. An agent re-sends its whole conversation prefix every turn; natively cached, that prefix bills at the cached-read rate (~10× cheaper than fresh input).
Pricing splits them out: cost = (in−cached)×ratein + cached×ratecached + out×rateout. The wholesale rates cp meters against (USD per 1M tokens):
| Served model | Input | Cached in | Output |
|---|---|---|---|
| Gemini Flash | $0.30 | $0.075 | $2.50 |
| Haiku 4.5 | $1.00 | $0.10 | $5.00 |
| Sonnet 5 | $3.00 | $0.30 | $15.00 |
| Opus 5 | $5.00 | $0.50 | $25.00 |
| Fugu | $4.00 | $0.40 | $20.00 |
| Fugu Ultra | $5.00 | $0.50 | $30.00 |
Worked example — a Sonnet turn with 40K input (30K of it a cached prefix), 1.2K output:
(10K×$3 + 30K×$0.30 + 1.2K×$15) / 1M = $0.057 wholesale. Bill the same turn without the cache breakpoint and the input alone is 40K×$3 = $0.12 — the cache turns a $0.138 turn into a $0.057 one.
What the box can and can't move
A tampered box could misreport its own token counts, but not the two things that set the price: the wholesale rate table and the served-model decision live only on cp. The box sees only its live balance and remaining allowance via /internal/usage-get.
Under-reporting only shrinks that tenant's own metered usage. It can't shift cost onto anyone else (metering is per-tenant, keyed on the deploy token), and it can't defeat the spend gate, checked on cp before a call is forwarded.
Allowance → credits: a dollar budget, not a token budget
Each managed plan includes a monthly token allowance (defaults 2M in / 800K out, per-user overridable). Rather than two token counters against two caps, we convert the allowance into one dollar budget at the plan model's list rate and meter cumulative wholesale cost against it.
Usage under budget is free. Past the line, only the incremental slice of each turn is charged to prepaid credits (1 credit = $0.01) at the LLM margin (4× wholesale ≈ 75% gross margin), billed at the model that served it. Metering the marginal overage per turn is what keeps the boundary crossing from double-billing.
Compute time (box running-hours beyond the included budget) meters onto the same credit balance. Threshold emails fire once each at 80% and 100% of allowance and when credits run low; at 100% usage switches to credits; when both are exhausted the 402 spend-gate stops the agent.
Learning the router: RL in shadow mode
The grader is a hand-built policy — the tier mapping, the judge prompt, the (12, 48) escalation thresholds, "grading failure → cheapest rung" are numbers a human guessed. And the judge sees only the message text, so a terse "fix the login bug" can grade trivial and pin a fifty-step debugging loop to a weak model.
Routing is a textbook contextual bandit: context = the request, action = the rung, reward = did it go well and what did it cost.
This runs in pure shadow — it does not change what any user gets. On every graded turn, control-plane/rl_router.py logs what a candidate policy would have routed, alongside the live judge's pick, in a router_shadow ledger.
The candidate sees features the text-only judge can't: message length, code fences, tracebacks, file paths, hard-vs-easy verbs, the tenant's recent escalation rate, the prior rung. No chat content is stored, only the feature vector.
The reward is backfilled from data we already keep. A turn's llm_calls rows — bounded by the tenant's next decision so back-to-back turns don't bleed together — aggregate into success − cost − mid-turn-escalation − hard-failure. Self-healing events (exhaustion, empty completions, auth rolls) don't count against the turn; a turn _ESCALATE_AT had to bump mid-flight does.
Those rewards train a model. Each rung is an arm with its own linear model of estimated reward, ra(x) = wa · x, fit online (SGD ridge, pure Python, no numpy). The candidate picks argmaxa ra(x) over the arms with enough data.
Why the arms are trained off-policy, and how cold-start works
We can only observe the reward for the rung the live judge served, so each arm trains off-policy, learning only from the turns that landed on it. This is the regression / "direct method" contextual bandit (LinUCB without the confidence matrix).
The hand-tuned scorer becomes the prior and cold-start: the learned policy stays dormant until it clears a data bar (≥30 turns on each of ≥2 arms, ≥300 total). The admin report shows which state it's in — warming up (heuristic prior) vs bandit driving — with per-arm sample counts.
An admin-only report (Models tab, /admin/api/router-shadow) backfills rewards on demand and summarizes judge-vs-candidate agreement, the confusion matrix, realized reward on the agreement subset, the escalation-catch rate, and a counterfactual cost estimate — an estimate because where the picks diverge the candidate's rung was never served, so its cost is modeled from that rung's realized mean.
Serving now exists too: a router_bandit_pct knob routes that fraction of graded turns via the trained bandit, with a router_bandit_max ceiling and the cheapest-funded fallback underneath. It ships default-off (0%) and is a no-op until the bandit clears its data bar. Turns it does serve are logged live_why = 'bandit', so their reward is measured rather than modeled.
The learning flywheel: a platform that learns from its own traffic
The RL router is one feature; the shape underneath it matters more. The loop is a pattern: any decision the control plane makes repeatedly with a measurable outcome can run through it. Routing was the first worth the trouble.
The loop, end to end
| Stage | What happens | Where it lives |
|---|---|---|
| Observe | Every graded turn writes a decision record: what was served, why, and a feature vector derived from the request — never the request itself. | router_shadow ledger |
| Reward | The turn's outcome is reconstructed from accounting we already keep: did it succeed, what did it cost, did it need a mid-turn escalation, did it hard-fail. No new instrumentation — the billing ledger is the reward signal. | backfill over llm_calls |
| Learn | Per-arm linear reward models train online (SGD ridge) on each newly scored turn — off-policy, each arm learning only from turns that genuinely landed on it. | router_bandit weights |
| Serve | A configured fraction of turns routes by argmax predicted reward instead of the judge — default 0%, ceiling-capped, one flag to roll back. | router_bandit_pct |
| Measure | Served turns are tagged, so bandit-vs-judge reward is compared measured, not estimated — and those turns train the policy on its own picks. | on-policy A/B in the report |
Each stage shipped separately, shadow-first, and each is independently reversible — the loop was assembled in production without ever being live in production until deliberately switched on.
One brain across the fleet
The bandit is fleet-shared, not per-tenant. Every managed box routes through the same proxy, so every user's turns pool into one policy — which matters for cold start and for compounding.
Cold start: one tenant might produce a few dozen graded turns a week, never clearing the activation bar (≥30 turns per arm, ≥300 total). Pooled, the platform clears it quickly.
Compounding: a lesson from one tenant's mis-routed turn — "terse messages mentioning a traceback spawn fifty-step debugging loops; start them higher" — improves routing for everyone. That is the flywheel: usage → reward data → better routing → better margins and better answers → more usage.
Shared weights don't mean uniform behaviour. Per-tenant character enters through the features — recent escalation rate, the prior turn's rung — so the same model routes a habitual refactorer differently from someone who mostly chats.
The caveat: early on the policy's opinions are dominated by whichever tenants are most active. Until the data broadens, the guardrails carry the risk — a tiny serve fraction, the router_bandit_max ceiling, and per-plan floors/ceilings.
Where the learning stops, on purpose
The loop covers exactly the traffic where the routing decision is ours to make — the managed auto ladder — and is excluded everywhere the user or the plan has already decided.
A tenant who pins a model short-circuits the resolver before the grader or the bandit is consulted. BYO-key boxes never enter the proxy, so they contribute no data and receive no routing opinions. Utility passes stay pinned to the cheapest rung. Mid-turn follow-up calls reuse the turn's decision.
Learning without reading
The whole loop runs on metadata only: the feature vector from §07 — plus question-mark and hard/easy verb counts — with the served model and the outcome. Not one byte of chat content.
That is also what makes fleet-pooling defensible: the shared policy carries no tenant's words, only the statistical shape of difficulty.
Why this is the interesting part
Hand-tuned heuristics rot — they drift out of date silently as models, prices and usage change. A learned policy re-fits continuously against realized reward per dollar, and because the reward folds in wholesale cost, it optimizes margin and user experience as one objective.
The same ledger → reward → bandit → flag-gated-serve pipeline sits there for other repeated decisions: which memory facts to inject into a turn (reward: did the turn go better?), when to sleep an idle box (reward: compute saved vs cold-wake pain), even which voice to synthesize with.
📄 The formal treatment — problem formulation, estimator and update rule, activation and serving math, figures, and references — is a technical report: Learning to Route: A Shadow-First Contextual Bandit for Per-Request LLM Model Selection (AVZ-TR-2026-01, rev. V4) — download the PDF ↓ (two-column, 6pp) or the LaTeX source ↓.
What it comes to on real traffic: the cheapest rung served 35% of turns for 0.9% of the model spend, and the same tokens priced at Sonnet alone would have cost 2.5× what routing actually spent. That is a comparison of cost for identical tokens, not a claim that a cheaper model would have answered as well — the grader routes up precisely when it judges a turn needs it.
Memory storage
Durable memory is a single DynamoDB table, partitioned per tenant (pk = t#<tenant>, sk = fact#<id> or conv#current).
The important decision is who holds the credentials. A shared box IAM role couldn't isolate one tenant's memory from another's — any box could read any partition. So boxes get no DynamoDB access at all. They call the control plane's /internal/memory API with their tenant token; cp does the I/O scoped to that tenant.
Living off-box, memory survives box rebuilds and is available from any box the tenant is routed to.
Passive learning: mining preferences from every turn
Memory started as an explicit tool — a fact was saved only when the user said "remember this." That misses most of what's worth learning. So after every completed turn the box fires a fire-and-forget background pass (_extract_preferences): one small judge call over that turn alone, asking for durable preferences, conventions, decisions, or corrections.
It returns a strict JSON array (max 3 facts, [] for most turns), and the box saves anything new — so the agent learns you prefer python3, or that deploys go to main, without being told twice.
Three choices keep it cheap and safe: it's out of the critical path (a daemon thread); it skips trivial turns; and it's deduped against known facts — current memory goes to the judge as a "do not repeat" block, and a case-insensitive check drops anything already stored.
Auto-learned facts are held to a different standard. Every fact carries its provenance (user vs auto), and Settings → Memory groups the two so you can see what the agent picked up on its own — and edit or delete any of it.
The reinforcement clock that expires stale auto-facts
Instead of a confidence score there's a reinforcement clock: saving a duplicate refreshes the original's seen timestamp and bumps its hit count, and an auto-learned fact that hasn't come up in ~60 days quietly expires (user-saved facts never do).
The renewal loop closes itself: facts nearing expiry are left out of the mining pass's "already known" list, so a preference that's still true gets re-extracted — resetting its clock — while one that stopped being true fades. Editing an auto-learned fact promotes it to user-owned, ending its decay.
Voice
Speech-to-text turns a mic recording into text; text-to-speech turns the reply into audio (a pay-as-you-go TTS API). The rule users asked for: talk only when talked to. A reply is spoken only when the turn started by voice; typed commands reply silently.
Hybrid speech-to-text, biased to your project
STT runs on whichever engine is configured. When a Deepgram key is present the box uses Deepgram Nova-3 (streaming Flux for hands-free, Nova-3 batch as the fallback); otherwise it falls back to ElevenLabs Scribe, and to the device's own recognizer if neither is reachable. Any failure degrades silently down that chain rather than dropping the turn.
The win is accuracy on the words a generic recognizer always fumbles: your file, folder, repo and made-up names. The same voice lexicon the client uses to fuzzy-correct transcripts is fed to Nova-3 as keyterm biases (capped at 100), assembled per turn from a built-in set plus the box's custom and harvested vocabulary — so "open app dot py" lands as app.py with no retraining, and adding a word is a settings edit, not a model change.
Hands-free, eyes-free
Hands-free mode is a full no-touch loop: it listens continuously, decides when you've stopped talking (an end-of-turn sensitivity the user tunes — Snappy / Balanced / Patient), sends on its own, and reads the reply aloud before listening again. The screen is just a moving waveform — nothing to read — and voice commands (stop, new chat, go to sleep for a "Hey Goose" wake-word mode) steer it. It was built for driving; it switches on automatically when the box detects you're on the road.
Apphost: shipping user apps
When the agent deploys something, it lands on apphost at <name>.avriz.app: a source tarball in, a Docker container out, with per-app CPU/memory limits. Builds run at low priority (nice) so a deploy never starves apps already serving traffic.
Build order: a user's Dockerfile if present → nixpacks auto-detection (Python, Node, Go, …) → a static-site fallback. nixpacks provisions the language runtime inside the container, so apphost stays language-agnostic.
Two failure modes bit us and are now designed out:
- "Builds fine, crash-loops on start." An app whose start command names an uninstalled binary (classic:
gunicornin the Procfile but not inrequirements.txt) builds, then exits 127 in a restart loop — a silent502. Fix: before building, if a Python start command names a WSGI/ASGI server (gunicorn/uvicorn/…) missing fromrequirements.txt, auto-add it. - A dead container behind the proxy. If a container isn't staying up shortly after deploy, apphost falls back to serving any
index.htmlas a static site, or reports the crash log — never a bare502.
Boxes ship with node, npm, python3, and common Python app servers preinstalled, so the agent can run Python and JS apps directly during development.
Provisioning
When someone starts a box — free signup, or a redeemed access code — the control plane launches one EC2 instance, tags it to the tenant, seeds it via cloud-init, and records the route. The box comes up with the box app, terminal, file manager, agent engines, and language runtimes.
Two guardrails: the control plane's IAM role can only launch t3.*/t3a.* instance types, denied at the policy level otherwise. And account recycling dry-runs the launch before destroying the old box, after an incident where destroying first and failing the relaunch meant permanent data loss (boxes are DeleteOnTermination).
Deploy pipeline
Everything is push-to-deploy. A commit to main triggers GitHub Actions, which ship via an OIDC role through S3 and SSM:
- control plane → the cp host.
- box app → running tenant boxes (rolled out live).
- apphost → ships only the agent, never the app data.
One operational gotcha: box-app deploys roll out to running boxes only. A box asleep during a deploy gets the update on its next wake, so verifying a change often means waking the box first, then re-running the rollout. SSH is firewalled, so all box operations go through SSM. Each artifact is stamped with a VERSION (build date + short SHA) and a derived codename.
Sleeping boxes catch up on wake
"Gets the update on its next wake" is a real mechanism. Every box's voiceide.service carries a systemd drop-in — ExecStartPre=-+/opt/voiceide/self-update.sh — that runs before the app starts, on every start, including a wake from stopped.
It pulls the latest bundle from the control plane (GET /internal/bundle, authenticated with the box's deploy token), extracts it over /opt/voiceide, and reinstalls requirements. That template is the one the pipeline's update-template job refreshed on the cp host, so a box that slept through ten deploys wakes current in one step.
What the two ExecStartPre prefixes buy
+ runs the pre-step as root (it has to chown and pip install); - means any failure — a network blip, a truncated tarball — is ignored, so a botched update can never keep the box from starting. The box either comes up newer, or comes up on exactly the code it had.
Security decisions, collected
The isolation principle, made concrete:
- Managed model keys never touch a box. They live only on the control plane, behind the metered proxy.
- Boxes have no database IAM. Tenant memory is isolated by the control plane, not by a role a box could abuse.
- BYO credentials stay scoped. A connected GitHub token is referenced from the box's environment, never baked into a config file a raw-config UI could echo back.
- UI actions bind by data-attribute, not string-built handlers. Values that flow into the DOM (recipe names, file names, extensions) attach via
data-*+ delegated listeners, not inlineonclickstrings — a JS-string context an HTML escaper doesn't neutralize. From a real stored-XSS on a filename. - The instance-type guardrail and dry-run-before-destroy block cost surprises and data loss at the IAM layer.
Horizontal scaling: the apphost front door
Apphost started single-node in a way that couldn't grow: avriz.app and *.avriz.app DNS pointed at one box's elastic IP, and that box's agent looked each subdomain up in its local database and proxied to a local container. A second apphost would receive no traffic and its apps would be invisible.
Phase 0 — placement registry. The control plane learned where each app runs. An apphost column on the apps table records the host; a pool abstraction (_apphost_pool/_pick_apphost) does round-robin placement for new apps and reaches existing ones on their recorded host.
Apps are sticky — a redeploy returns to the same host, because the container and its per-app Postgres/Redis live there. Destroy, restart, logs, and status all route by the registry. On one host it's a no-op; groundwork.
Phase 1 — cp becomes the *.avriz.app front door. DNS moved off the apphost's IP to the control plane's. cp terminates TLS for every app subdomain (on-demand issuance) and reverse-proxies each request to the apphost that runs it, preserving the Host header. Adding a second apphost is a config change, not a rewrite.
The hairpin-NAT detail that forced the private-network hop
An instance can't reach its own public elastic IP from inside the VPC (hairpin NAT fails), so cp reaches the apphost over the private network — avriz.app is pinned to the apphost's private address in /etc/hosts, resolved fresh on every deploy so a replacement can't leave a stale pin.
Hardening + cleanup. The apphost's public IP was deliberately kept — its subnet routes egress through an internet gateway with no NAT, so that IP is the only outbound path for Docker/nixpacks/npm during builds — but inbound was locked to the control-plane security group. No public :80/:443.
The internal hop was then simplified from HTTPS-with-an-un-renewable-cert to plain HTTP straight to the agent on the private network, removing a redundant proxy layer, a Sep-2026 certificate time-bomb, and duplicate response headers. Apps serve valid public HTTPS throughout.
The git-aware file manager
The box UI carried two overlapping tabs: Code (git-aware, but a flat file list around an editor) and Files (a folder-tree browser, blind to git). They're now one git-aware file manager — one fewer item in the mobile bottom nav.
It keeps every file-manager affordance (folder tree, create/rename/delete/move, upload/download, in-place editor, markdown and image preview) and makes git a layer on top:
- Status badges on every entry — modified, added, deleted, renamed, untracked — with folders rolling up "changes inside." Status is computed against each entry's own enclosing repo, so it works at the workspace root, which isn't itself a repo.
- A "changed" filter, a repo/branch header, and Commit / Open PR buttons.
- Per-file actions: view diff, stage, unstage, and discard (confirm-gated).
- An inline diff viewer with an AI summary — it explains the change in plain language and proposes a conventional-commit message you can commit in one tap, or open a PR prefilled with that title and summary.
Backend-wise this reused what existed (commit, PR, diff-summarize, branches) and added a few endpoints: an enriched directory listing carrying per-entry git status, plus stage / unstage / discard and a per-file diff, all repo-relative to the file's own repository.
Three bugs only a live box surfaced
Empty diffs for untracked files (a status helper that dropped output on git's non-zero "there is a difference" exit code); a discard that didn't remove a staged-new file; the workspace-root view emptying because the root isn't a repo.
Giving the agent a browser
A coding agent that can't see what it built is working blind. The agent now drives a real headless Chromium on the box — open a page, wait for it to render, read the console, click through a flow, screenshot the result (screenshots land in Files).
It's wired as a Playwright MCP server, so "go look at the page" is another tool call in the same turn as the code that changed it.
The wrong bug we chased first
The browser extension was configured in Goose's config.yaml exactly like developer, memory, and the rest — yet the agent kept insisting it had no browser tools, that it was "text-based," that headless wouldn't work.
Every one of those replies was the agent honestly reporting what it could see: the tools genuinely weren't loaded. The lesson: trust the agent's account of its own capabilities and go find why they're empty.
The root cause is a sharp edge in how Goose runs. Driven over ACP — which is how our box app talks to it — goose serve does not load the stdio extensions from config.yaml at all. Those are a CLI/desktop convenience.
In ACP mode the client declares which MCP servers a session gets, via an mcpServers array in session/new (and again in session/load on resume). We were passing an empty array, so every session came up with only Goose's built-ins. Playwright, context7, github, our own memory server: all silently absent.
The fix is _acp_mcp_servers(): read the box's Goose config, expand ${VAR} references, forward the right environment (HOME, PATH, the GitHub token), and hand the resolved stdio server list to both session/new and session/load.
One defensive detail from the outage: sessions start with a two-step fallback (_attempts = [_mcp, []]) — try the full extension set, and if that handshake fails, retry with none. A missing browser is a degraded turn; a session that won't start is a broken product.
Project knowledge you can bring
Durable memory (§08) is short, personal, and cross-project — "use python3," "deploys go to main." That's the wrong shape for project knowledge: a schema, an API contract, a house style, the runbook for a gnarly deploy. That belongs in the repo, versioned alongside the code it describes.
So the agent keeps a knowledge base as a folder of markdown files — one concept per file, YAML frontmatter plus a body, cross-linked like a small wiki, with an index.md overview. The format is Google's Open Knowledge Format (OKF): plain, human-editable, portable, reviewable through git.
A house rule plus a Goose skill teach the agent to maintain it: when it learns something durable about the project, it writes or updates knowledge/<area>/<concept>.md in place rather than duplicating, and keeps the index current.
The other half is bringing your own. Upload a coding standard, a spec, a style guide (More → Knowledge) and the agent treats it as authoritative — a convention it follows, not a suggestion it weighs. The upload reuses the file manager's /api/fs/upload endpoint and drops the document into the knowledge folder.
The distinction from memory: memory is what the agent picked up about you; knowledge is ground truth about the project.
Many chats, each with its own memory
The box began with a single running conversation — fine until two things run at once and the agent's context for one bleeds into the other. Now the box keeps multiple chats: start, switch, rename or delete them, each with its own thread and agent context.
This leans on the same two-layer split from §06. The visible transcript of each chat is a durable record in DynamoDB (conv#<id> partitions alongside the tenant's facts, with a conv#current pointer to the active one), so reopening a chat repaints what was said.
The agent's memory of a chat is its Goose ACP session; switching chats calls session/load on that chat's session id, so the agent resumes mid-thought. New chats are auto-titled from their first exchange, and a migration on first load folds every existing single-conversation box into the model — the old conv#current becomes chat one.
On top: a search box over the chat list, and a recursive filename search in the Files tab that walks the whole workspace, skipping heavy directories and capped so a giant tree can't hang the UI.
How the changelog ships itself
Customer communication is built like the rest of the product. CHANGELOG.md is the source of truth and fans out to three destinations with no hand-copying. Each user-visible change adds one plain-language bullet under ## Unreleased. From there:
- The public page.
avriz.io/changelogrenders the customer-facing sections of that file (internal "how the email works" and "engineering notes" sections are filtered out).CHANGELOG.mdis in the deploy-cp path filter, so editing the changelog is a deploy of the page. - The weekly email. A scheduled GitHub Action fires Monday 9am (America/New_York), copies the changelog to the cp host over SSM, and runs a script that builds the update from the Unreleased bullets and sends it through the same SES + branding path as every other transactional mail.
- The archive. After a successful send the job moves the Unreleased bullets into a dated
## YYYY-MM-DD — emailedsection and commits back tomain. That commit stops the same bullets going out twice — and refreshes the public page in the same motion.
Two edges designed in rather than discovered
GitHub cron is UTC-only and 9am ET drifts with daylight saving, so the schedule fires at both the EDT and EST offsets and a guard step lets only the one that's actually 9am in New York proceed.
And a Monday with nothing under Unreleased does nothing — no empty email, no failed run. The alternative is the kind of automation that erodes trust in itself.
Vision: from a screenshot to a spec
The fastest way to describe a UI is to show one. So the composer grew a + button: drop in a screenshot, a Figma export, a photo of a whiteboard sketch — or a PDF or markdown spec — and the agent builds from it.
The division of labour is deliberate: vision only sees; the coding agent still builds. The file is read into a self-contained build spec (layout, components, exact text, colours, spacing), that spec drops into the composer for you to glance over, and Send hands it to the same Goose loop (§05).
The prompt reads intent, not pixels, judging first what kind of image it has. A polished mockup or screenshot is reproduced faithfully — exact text, colours, spacing.
A flowchart, user-flow, or boxes-and-arrows sketch is not drawn literally; the model deduces the application it describes. Each box becomes a screen or component, each arrow navigation or a state change, and the UI the diagram only implies (real inputs, buttons, empty and error states) is filled in with sensible defaults.
Hand it a five-box "login → task list → detail → edit → done" sketch and you get a spec for a real task app with those screens and the navigation between them, plus a note on anything it inferred. A third case: a written spec, PDF or markdown, becomes the UI those requirements call for rather than restated text.
It runs on the control plane, not the box. Boxes are small by design (§04, scale-to-zero on a t3a), with no GPU. The box relays the file bytes to one deploy-token-authed endpoint, /internal/vision, which calls Amazon Bedrock's Converse API and returns the spec.
Images ride an image content block, PDFs and markdown a document block (Nova reads them visually and as text). The model id lives behind a vision_model config knob (default amazon.nova-lite-v1:0), so swapping models is a one-line config change, not a deploy.
Why not Rekognition
Wrong shape of tool. Rekognition detects and classifies — labels, faces, moderation, OCR text strings — but has no language reasoning: point it at a mockup and you get "Text", "Page", and a bag of detected words with bounding boxes, never "this is a login card with an email field, a full-width teal button, and a 'forgot password' link."
The value here is understanding the design and writing how to build it — a vision-language job. The cost intuition inverts too: a Nova Lite read of a mockup runs a few ten-thousandths of a dollar, cheaper per image than a Rekognition DetectText call. Bedrock is only "expensive" if you reach for a frontier model.
The one real cost lever is resolution, not bytes. Bedrock prices vision by how many tiles an image occupies, so a 12-megapixel phone photo is charged for far more than a mockup needs.
The browser therefore downscales before upload — cap the long edge at ~1280px on a canvas, dropping a big photo's pixel count roughly tenfold (and its input tokens by a third to a half) with no loss of anything Nova needs to read. Images already small and crisp pass through untouched; only oversized or oddly-formatted ones are re-encoded.
Metering rides the same path as a text turn (§07): Bedrock token counts fold into llm_cost, the monthly allowance, and the overage-to-credits ledger. Image bytes only, no server-fetched URLs — so there's no SSRF surface — and it fails closed without surfacing an AWS ARN or account id.
This document lives at /eng. · Last built Jul 2026. · Added §16 the agent's browser (the ACP mcpServers root cause), §17 bring-your-own project knowledge (OKF), §18 multi-chat sessions, and §19 the self-shipping changelog; completed §12 with the boot self-update-on-wake mechanism. · Added §21 vision — screenshot → build spec on Bedrock Nova Lite, with browser-side downscaling for frugality. · §06 now covers the pluggable agent-engine seam (Goose / OpenCode) and §10 the hybrid Deepgram STT with project-vocabulary biasing; refreshed for the free-first model.