This is the full developer documentation for Guuey
# Build an agent. Guuey runs it.
> Bring a system prompt, a model, and the MCP servers your agent needs. Guuey hosts it on isolated, always-on infrastructure, streams every conversation in real time, and puts it in front of users — no agent loop, no polling, no servers to babysit.
Get started
[Getting started](/getting-started/)Zero to a deployed, live agent in minutes — no code required.
Tutorials
[Your first agentic app](/tutorial-first-app/)Scaffold a code-mode agent, run the whole stack locally, and deploy it — step by step with real output.
[Ship a product shell](/tutorial-agentic-app/)The agentic-app template: your product's pages with an agent rail built into the sidebar, and a QR straight to mobile.
[Start from Trimly](/tutorial-trimly/)Fork the open-source app behind the live salon demo and re-brand it into your own.
Build with code
[CLI](/cli/)The code path: scaffold locally, bring your own framework code, guuey deploy.
[Agents as code](/agents-as-code/)Keep guuey.json in your repo and converge the hosted agent from CI — one idempotent apply, hashes to gate on, provenance you can read back.
Run
[Hosting & runtime](/hosting/)An isolated runtime per app, always-on pods, SSE streaming, persisted conversations.
[State & memory](/state-and-memory/)Managed history, a per-user KV store, per-user files, consent-gated profiles — durable for signed-in users.
[Plans & billing](/plans-and-billing/)Per-app plans and the 7-day trial, pod sizes and scaling, metered usage, and spending caps.
Distribute
[Embed & share](/embed/)One script tag puts the chat widget on your own site; share links give anyone a hosted chat page.
[Your own domain](/domains/)Serve your agent from a hostname you own — one CNAME for a subdomain (root domains work too), automatic verification and TLS.
Chat surfaces
[Drop-in chat UI](/chat/)Mount @guuey/chat and get the transcript UI Guuey's own surfaces use — streaming, tools, generative-UI cards, every failure state designed.
[Build your own surface](/sdk/)The @guuey/agent-client SDK: streaming, status, and continuity for a fully custom chat UI.
Identity & sign-in
[Identity & sign-in](/page-sign-in/)How visitors sign into your agent — Guuey sign-in out of the box, your own identity endpoint, or a redirect to your identity provider.
Concepts
[How hosting works](/concepts-hosting/)The machine underneath your app: one long-running pod per app, fixed sizes, per-user pinning, pause and resume. More concepts in the sidebar.
[How billing works](/concepts-billing/)Per-app subscriptions, the trial state machine, the four usage meters, and what the spending cap does and does not bound.
Product
[Use the Console](/console/)Operate every agent you host — deployments, sessions, logs, tools, design, users, and billing in one place.
[Use Portal](/portal/)The agent App Store and universal chat client where users find and talk to your agent.
Reference
[Generative UI (ggui)](/protocol/)Agents that keep the default MCP server reply with interactive screens, not just text.
[Security & isolation](/security/)How Guuey isolates workloads, guards credentials, and deletes data — the mechanisms.
[API & contract stability](/stability/)What you can build on today, and the compatibility posture Guuey's contracts take at general availability.
[What wraps your prompt](/prompt-wrapping/)The exact text the platform adds around your system prompt — published verbatim, checked against source on every build.
***
**Where things live:** [guuey.com](https://guuey.com) is the platform — the Console, Portal, hosting, these docs. [docs.ggui.ai](https://docs.ggui.ai) documents the open **ggui** generative-UI protocol and its SDKs; this site documents the platform.
Reading these docs as an LLM? Every page is also raw markdown at the same slug — start at [`/llms.txt`](/llms.txt), or see the [machine-readable surface](/agents/).
# 404 — page not found
> Nothing lives at this URL. Try the home page, the getting-started guide, or the search box in the sidebar.
# For LLM agents
> Machine-readable resources for LLM agents and coding-assistant devtools — aggregated dumps, per-page .md companions, stable anchors.
This page indexes Guuey’s machine-readable doc surfaces — llms.txt dumps, per-page .md companions, stable section anchors — for **non-human readers**: LLM agents, coding-assistant devtools (Claude Code, Cursor, Cline, Continue), evaluators, and scrapers. Humans, the rest of the site is for you.
## What’s available
[Section titled “What’s available”](#whats-available)
| Resource | URL | When to fetch |
| --------------------- | ------------------------------------ | ----------------------------------------------------------------------------------------------- |
| Entry point | [`/llms.txt`](/llms.txt) | First contact. [llms.txt format](https://llmstxt.org/) — links the abridged and complete dumps. |
| Whole-site dump | [`/llms-full.txt`](/llms-full.txt) | One-shot context loading. Drop into your window for cross-topic tasks. |
| Compact dump | [`/llms-small.txt`](/llms-small.txt) | Smaller one-shot context when `/llms-full.txt` is too big. |
| Per-page raw markdown | `/.md` | Reading one specific page. No HTML, no chrome. |
| Stable anchors | `//#` | Deep-linking to a section. Every H2/H3 has a Starlight-derived id. |
## Per-page `.md` companions
[Section titled “Per-page .md companions”](#per-page-md-companions)
Every page is also served as raw markdown at the same slug with a `.md` extension:
* [`/getting-started.md`](/getting-started.md) — zero to deployed agent
* [`/hosting.md`](/hosting.md) — the runtime model
* [`/cli.md`](/cli.md) — command reference
* [`/state-and-memory.md`](/state-and-memory.md) — persistence surfaces
The `.md` response is the source markdown with a small `---\ntitle: ...\n---` envelope and **no other transformation**. (One page — the homepage — is authored as MDX; its `.md` companion keeps the component import as-is. Treat that line as noise.) Fetch from any origin:
```bash
curl https://docs.guuey.com/hosting.md
```
CORS is open (`Access-Control-Allow-Origin: *`); `Cache-Control` permits 5-minute CDN caching. Every HTML page also carries a `` pointing at its companion, so alternate-representation-aware fetchers can auto-discover it.
## When to use which
[Section titled “When to use which”](#when-to-use-which)
```plaintext
You want → Fetch
─────────────────────────────────────────── ──────────────────────
Find the machine-readable surface /llms.txt
Drop everything into context (one-shot) /llms-full.txt
Read one specific page /.md
Deep-link to a section in conversation //#
```
## Acting on Guuey, not just reading
[Section titled “Acting on Guuey, not just reading”](#acting-on-guuey-not-just-reading)
If your task is to deploy or converge an agent — not to answer questions about the docs — go to [Agents as code](/agents-as-code/): `guuey agent apply` is an idempotent reconcile of a checked-in agent definition, built for non-interactive runs. Non-interactive auth uses app-scoped service tokens (`guuey_svc_…`) or workspace API keys (`guuey_wkz_…`) passed via `GUUEY_API_KEY`; that page carries the detail, including the raw `POST /v1/apps/:appId/reconcile` route if you’d rather skip the CLI.
## Related machine-readable surfaces
[Section titled “Related machine-readable surfaces”](#related-machine-readable-surfaces)
The open ggui protocol (generative UI used by Guuey agents) documents itself the same way at [`docs.ggui.ai/llms.txt`](https://docs.ggui.ai/llms.txt) — fetch that too when your task involves the protocol wire surface or SDKs rather than the hosting platform.
# Agents as code
> Keep your agent's prompt, definition, and access policy in your repo and converge the hosted agent from CI — one idempotent call, hashes to gate on.
Your agent is a `guuey.json` and a prompt file. Check them into your repo, review changes in pull requests, and let CI converge the hosted agent on merge — the same way you would apply a Kubernetes manifest or a Terraform plan. Guuey’s side is one idempotent call: it converges the app’s access policy and deploys the artifacts, never rolls the pod when nothing changed, answers with the sha256 of what it persisted, and records which commit built it so you can read it back later.
## The manifest
[Section titled “The manifest”](#the-manifest)
`guuey.json` is the whole manifest. The `agent` section is what the pod runs (framework, model, prompt, MCP servers — see [the CLI page](/cli/)); the `app.access` block declares the app record’s **access policy**, using the same field names as the platform API:
```json
{
"schema": "1",
"appId": "7365f90e-…",
"agent": {
"framework": "claude-agent-sdk",
"model": "claude-sonnet-5",
"systemPrompt": { "file": "prompts/system.md" },
"mcpServers": {}
},
"app": {
"access": {
"userAuthMode": "byo",
"userAuthConfig": { "issuerUrl": "https://id.example.com", "audience": "console" },
"allowedDomains": ["https://console.example.com"],
"guestAccess": false
}
}
}
```
* `access` is **PUT-per-field**: a field you don’t mention is never reset. `"userAuthConfig": null` is the explicit “clear the issuer binding”. Values are validated exactly like `guuey apps update` (`userAuthMode` ∈ `anonymous | native_pool | byo`; `allowedDomains` are bare domains or full origins, no wildcards).
* **Tier is not in the manifest.** Plan/tier is the app’s, changed on the app’s billing page in the console (see [Plans & billing](/plans-and-billing/)); a CI credential never carries it. Live knobs that are not build material (`maxPods`, runtime updates) stay on `guuey agent config` — see [Hosting](/hosting/) for scaling and runtime-update semantics.
* `app.builtFor` says who the agent is for: `"personal"` (your own assistant) or `"customers"` (a rep for the people you serve). Leave it out and the app keeps whatever it has; an app that never set it reads as `"customers"`. Any other value is refused with a `400`.
* The `agent.systemPrompt` file reference is resolved by the CLI: it sends the file’s bytes alongside the document and Guuey inlines them byte-exact.
* `app.theme` declares the app’s chat theme as code — the same document the console’s Design page stores (see [Theming](/chat-theming/)). Write it inline, or reference a file with `"theme": { "file": "theme.json" }` — the CLI reads the file at apply time (relative to `guuey.json`) and submits the resolved document, which is what gets validated and hashed. The platform rejects an unresolved reference, so an out-of-date CLI fails loud instead of half-applying.
Built your agent without code? `guuey pull` brings its live definition into a local `guuey.json` and externalizes the prompt to `prompts/system.md` — check those in and manage the app from the repo from then on.
GitOps means the repo wins
Once you manage an app this way, a change made in the console (auth mode, allowed origins, guest access, the prompt itself) is **overwritten by the next apply** if the manifest still declares the old value. Make the change in the repo — or remove the field from `app.access` if you want the console to own it. An agent whose latest build came from `apply` is marked **Managed by ``** where you’d hand-edit it, and deploying a hand-edit from the console makes that build the latest (no provenance, so the marker drops) until the next `apply` restores the repo’s bytes.
## `guuey agent apply`
[Section titled “guuey agent apply”](#guuey-agent-apply)
Run it from the directory that holds `guuey.json`:
```bash
guuey agent apply
```
```plaintext
✓ Applied — build #12 queued (provenance acme/agents@da49f6dcaabc).
snapshot: changed
config:
guestAccess: (unset) → false
sha256 agentDef: 5f2c…
sha256 prompt: 9a01…
sha256 snapshot: c7d4…
status: guuey deployments · /v1/apps/…/deployments/12/status
```
What one apply does, in order:
1. Validates the document with the same schema and lints as `guuey deploy` (secrets in headers, reserved MCP names, tool-gate grammar, …).
2. Computes the **plan**: does the assembled snapshot byte-match the newest active build, and does every declared `access` field already hold?
3. If nothing changed → `Unchanged — build #N (live) already serves these bytes. Nothing queued.` The pod is **not** rolled. This is what makes a no-op CI run safe.
4. Otherwise converges the differing `access` fields (fail-closed: a bad value is rejected before anything is written) and queues a new build.
Flags:
| Flag | Meaning |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `--dry-run` | Plan only. Prints the diff + hashes; **exit 2** when there is drift, 0 when in sync, 1 on error. Nothing is written. Trigger-side gates (tier size ceilings, build caps) are enforced on the real apply. |
| `--wait` | After applying, poll the build until it is live (or failed / superseded). |
| `--provenance
` | `auto` (default): `GITHUB_REPOSITORY`/`GITHUB_SHA` when set, else `git remote get-url origin` + `git rev-parse HEAD`, path = cwd relative to the repo root. `none`: don’t record. `@[:]`: explicit. |
| `--json` | Emit the response verbatim (see below). |
| `--app-id ` | Target another app — e.g. one manifest applied to a staging and a production app. |
### The response, and the drift gate
[Section titled “The response, and the drift gate”](#the-response-and-the-drift-gate)
The `--json` response is the contract your pipeline gates on:
```json
{
"applied": true,
"unchanged": false,
"dryRun": false,
"appId": "7365f90e-…",
"buildNumber": 12,
"status": "queued",
"deployedContentHash": {
"agentDef": "",
"systemPrompt": "",
"snapshot": ""
},
"provenanceRecorded": true,
"statusPath": "/v1/apps/7365f90e-…/deployments/12/status",
"plan": {
"snapshot": "changed",
"config": [{ "field": "guestAccess", "current": null, "desired": false }]
},
"convergedFields": ["guestAccess"],
"shadowedDocFields": ["standalonePage"]
}
```
`agentDef` and `systemPrompt` hash **your bytes verbatim** — compare them with `sha256sum guuey.json prompts/system.md` and fail the job on a mismatch (“green means deployed”). `snapshot` hashes the exact string persisted on the build row: the platform assembles it from your document (prompt inlined, the app’s generative-UI binding injected) in a canonical form, so identical inputs always produce the identical hash — that is what `unchanged` compares.
`convergedFields` is on **every** response: the config fields the server actually diffed this call — every field present in the effective desired config, whether or not it produced a change (`plan.config` is always a subset), in a stable canonical order. Gate on it: a pipeline that asserts its declared blocks appear in `convergedFields` catches a silently dropped block immediately. Dropping one is easier than it sounds — if your request carries an explicit `config`, that config **replaces** the document-derived one wholesale (it is not merged field-by-field), so a block your document declares can quietly stop converging. When exactly that happens, `shadowedDocFields` names the document-declared fields your explicit `config` suppressed — a warning, never an error (the override is legitimate; gate on it or not, your choice), and omitted when empty. A field present in both with a *different value* is not a shadow — that is the override doing its job. `guuey agent apply` prints the echo as a `converged:` line and any shadow as a `!` warning.
## `guuey agent status`
[Section titled “guuey agent status”](#guuey-agent-status)
```bash
guuey agent status
```
```plaintext
App: 7365f90e-…
Live: build #12 (live) · xs · deployed 2026-08-16T20:04:11.000Z
managed from acme/agents@da49f6dcaabc (agents/helper)
snapshot sha256 c7d4…
Config:
userAuthMode: "byo"
userAuthConfig: {"issuerUrl":"https://id.example.com","audience":"console"}
allowedDomains: ["https://console.example.com"]
guestAccess: false
```
Add `--check` to also compare **this checkout** byte-exact against the live build (a dry-run reconcile behind the scenes): `✓ Checkout is in sync with build #12 (byte-exact).` (exit 0) or `✗ DRIFT — this checkout differs from build #12:` followed by the diff (exit 2). A build that was deployed outside agents-as-code (`guuey deploy`, the console) shows `no provenance`.
`guuey deployments` lists every build (`--json` includes each build’s provenance and hash); `GET /v1/apps/:id/deployments/:n` serves provenance and hash per build.
## `guuey agent rollback --to `
[Section titled “guuey agent rollback --to \”](#guuey-agent-rollback---to-n)
Re-serve a previous build’s **exact bytes** as a new build — no checkout, no rebuild, from anywhere with `--app-id` (why this can be byte-exact — immutable snapshots, row-driven serving — is explained in [Deployments & rollback](/concepts-deployments/)):
```bash
guuey agent rollback --to 7 --wait
```
```plaintext
✓ Rolled back — build #9 queued, re-serving build #7 byte-exact — bytes from acme/agents@1f0e2d3c4b5a (agents/helper).
sha256 snapshot: c7d4…
```
The source is build #7’s persisted snapshot — the string its pod booted from — so the new build serves what served then, byte for byte, and its row keeps #7’s provenance (`guuey agent status` then reads `build #9 (live) (rolled back from #7)`, still `managed from acme/agents@…`). Every gate a fresh deploy passes runs again (schema, lints, tier and size ceilings, build caps). If the pinned bytes are already what is live, nothing is queued. Rollback **refuses rather than approximates**: a build whose stored snapshot would no longer re-serialize byte-identical (the platform’s canonical form moved since — a schema default, the app’s generative-UI binding), a code build, or a row without a snapshot answers `409 ROLLBACK_NOT_EXACT` with the way out (`guuey agent apply` from that commit). Rollback re-serves build material only — it never touches the app’s access policy; if the manifest at that commit declared a different `app.access`, apply from that commit instead.
Flags: `--wait`, `--json`, `--app-id `; the wire is `POST /v1/apps/:id/reconcile/rollback { "buildNumber": 7 }` → `{ applied, unchanged, rolledBackFrom, buildNumber, deploymentId, status, contentHash, provenance, statusPath }`, callable with the same credentials as `apply`.
## The CI credential: an app service token
[Section titled “The CI credential: an app service token”](#the-ci-credential-an-app-service-token)
Do not put a personal login in CI. Mint an **app-scoped service token** once, as the app’s owner:
```bash
guuey tokens create --label "github-actions" --app-id
```
The secret (`guuey_svc_…`) is printed **once**. It is bound to that one app, may only call the reconcile routes (`agent apply` / `agent status` / `agent rollback`) and read that app’s deployment history — no tier, no billing, no delete, no other apps, and `guuey deploy` refuses it. It never expires; `guuey tokens revoke ` is the kill switch. Store it as a CI secret and pass it through `GUUEY_API_KEY`.
A personal key via `GUUEY_API_KEY` also works (full user authority, real expiry) — fine for a laptop, wrong for a pipeline.
### Workspace-owned apps: the CI credential is a workspace API key
[Section titled “Workspace-owned apps: the CI credential is a workspace API key”](#workspace-owned-apps-the-ci-credential-is-a-workspace-api-key)
An app owned by a **workspace** (a team) cannot mint a service token — a token bound to whichever member minted it would outlive that member’s seat. Its CI credential is a **workspace API key** (`guuey_wkz_…`), which authenticates as the workspace itself: a workspace admin creates it in the console at **Settings → Workspace → *your workspace* → API keys → Create key** (`/settings/workspaces//api-keys`); the secret is shown once. Store it as a CI secret and pass it the same way, through `GUUEY_API_KEY` — `guuey agent apply | status | rollback` and the raw reconcile routes accept it for every app that workspace owns. Its scope is fixed by the platform, not per key: the deploy plane (reconcile, deploy, deployment reads and status, env, logs, config, generative-UI asset push) — never app creation or deletion, listing, domains, slugs, branding, service-token or workspace-key management, or anything that needs a named member behind the call. Revoke it from the same console page. (No `guuey wkeys` CLI verb yet — creation is console-only.)
## GitHub Actions example
[Section titled “GitHub Actions example”](#github-actions-example)
Two jobs: a plan on every pull request, an apply on merge to `main`. The same manifest can target one app per environment with `--app-id`.
```yaml
name: agent
on:
pull_request:
paths: ["agents/helper/**"]
push:
branches: [main]
paths: ["agents/helper/**"]
env:
GUUEY_API_URL: https://api.us-east-1.guuey.com/v1
GUUEY_API_KEY: ${{ secrets.GUUEY_SERVICE_TOKEN }}
jobs:
plan:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: npm install -g @guuey/cli
- name: Plan (a diff is expected on a PR — exit 2 is the plan, only a real error fails)
working-directory: agents/helper
run: guuey agent apply --dry-run || test $? -eq 2
apply:
if: github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: npm install -g @guuey/cli
- name: Apply
id: apply
working-directory: agents/helper
run: guuey agent apply --wait --json | tee reconcile.json
- name: Drift gate — the bytes Guuey persisted are the bytes we reviewed
working-directory: agents/helper
run: |
want_def=$(sha256sum guuey.json | cut -d' ' -f1)
want_prompt=$(sha256sum prompts/system.md | cut -d' ' -f1)
got_def=$(jq -r .deployedContentHash.agentDef reconcile.json)
got_prompt=$(jq -r .deployedContentHash.systemPrompt reconcile.json)
test "$want_def" = "$got_def" && test "$want_prompt" = "$got_prompt"
```
`GITHUB_REPOSITORY` and `GITHUB_SHA` are set by Actions, so provenance is recorded automatically; `guuey agent status` on the app then reads `managed from /@ (agents/helper)`.
## The API, if you’d rather not run the CLI
[Section titled “The API, if you’d rather not run the CLI”](#the-api-if-youd-rather-not-run-the-cli)
Everything above is one route:
```plaintext
POST /v1/apps/:appId/reconcile Authorization: Bearer
{
"artifacts": { "guueyJson": "", "systemPrompt": "" },
"config": { … same fields as app.access, plus builtFor — optional; when absent, the document's app section is used },
"provenance": { "repo": "org/repo", "path": "agents/helper", "sha": "" },
"deploymentId": "",
"size": "xs — optional pod size, same values as guuey deploy --size",
"dryRun": false
}
GET /v1/apps/:appId/reconcile → { appId, live: { buildNumber, status, deploymentId, deployedAt, size, provenance, contentHash, rolledBackFrom } | null, config: { … } }
POST /v1/apps/:appId/reconcile/rollback { "buildNumber": 7 }
→ { applied, unchanged, appId, rolledBackFrom, buildNumber, deploymentId, status, contentHash, provenance, statusPath }
```
Errors are always JSON `{"error": {"code", "message"}}`, and the call is idempotent, so retries are safe. While an app is on its [7-day trial](/plans-and-billing/), `GET /v1/apps/:id` carries `trial: { startedAt, endsAt, status }` (`null` once the app is off the trial); a deploy or reconcile against an expired, unpaid app answers `409 TRIAL_EXPIRED` — choosing a plan on the app resumes it, no redeploy needed. A `guuey.json` document must carry `"schema": "1"` at the root and only the documented sections — an unrecognized root key is rejected by name.
### Schema versions
[Section titled “Schema versions”](#schema-versions)
The root `schema` is a decimal string (`"1"` today), bumped only when the document shape changes in a way an older reader cannot interpret. The CLI and the platform compare against the **same** constant (`SUPPORTED_GUUEY_JSON_SCHEMA` in `@guuey/config`), and both refuse rather than guess: `guuey agent apply` (like `guuey deploy`) stops before any network call with `[SCHEMA_TOO_NEW]` and tells you to upgrade `@guuey/cli` when the document is newer than the CLI; the reconcile and deploy-trigger routes answer `400 SCHEMA_UNSUPPORTED` when a document is newer than the platform serves. An **older** schema is accepted only when a migration to the current one exists — none does yet, so today the rule is simply “equal, or refused”. A document that passes locally and gets `SCHEMA_UNSUPPORTED` from the API means your CLI is ahead of the platform; wait for the platform to catch up rather than downgrading the file.
## Not (yet) converged from the manifest
[Section titled “Not (yet) converged from the manifest”](#not-yet-converged-from-the-manifest)
Slug, custom domain, store listing, and branding have their own ceremonies (claiming, DNS verification, uploads) and stay on their commands for now. Tier and billing never move to CI. Workspace-owned apps cannot mint service tokens — use a workspace API key (above).
# Drop-in chat UI
> Mount @guuey/chat's and get the transcript UI guuey's own surfaces use — streaming, tools, generative-UI cards, every failure state designed.
[`@guuey/chat`](https://www.npmjs.com/package/@guuey/chat) (MIT) is the default chat UI for guuey agents — the same transcript tier guuey’s own surfaces run: the embeddable widget, the console’s playground, and the Portal apps all render through this package. Mount it with your agent’s endpoint and you get a production chat surface; keep customizing until only the parts you care about are yours.
The design bar it holds: **an agent transcript stays readable in every weird case** — a 40-second turn running a dozen tools, a generative view mounting mid-stream, a failed tool, a cold-started agent, a dropped connection. Each of those is a designed state, not an accident. A `ggui_render` refusal is a named state (as of `@guuey/chat` 0.18.1 with `@ggui-ai` 0.15.0): `@guuey/mcp-apps-host` parses the tool result’s `structuredContent` against the protocol’s render output schema into a typed refusal, and the row renders as **Not rendered** with the refusal’s `code` — every tool row carries a `refusal` field, `null` unless one parsed. Anything that isn’t a parsed refusal still takes the generic failed-tool path: the row shows ✕ and its expansion is the result’s `structuredContent` as a bounded JSON preview, with no card mounted.
```sh
npm install @guuey/chat
```
## Mount it in ten lines
[Section titled “Mount it in ten lines”](#mount-it-in-ten-lines)
```tsx
"use client";
import { GuueyChat } from "@guuey/chat/react";
import "@guuey/chat/styles.css";
export function AgentChat() {
return (
);
}
```
Both values come from your deploy: the endpoint is printed by `guuey deploy` (and shown by `guuey apps get`), the app id by `guuey apps list`. Before your page goes to production, [allow its domain](/sdk/#allow-your-domain) and verify with `guuey apps check --origin` — same rules as any custom surface.
## What you get for free
[Section titled “What you get for free”](#what-you-get-for-free)
* **Streaming text** as sanitized markdown — raw HTML from the model is structurally unrepresentable in the rendering pipeline, not merely escaped.
* **Tool activity as calm one-liners** — a running tool is a live status line; consecutive finished tools collapse into one expandable “Ran N tools” row; a tool that produces something visible (a generative-UI card, media, a prompt) always renders in place, never buried in a collapse.
* **Generative-UI cards mount sandboxed** — agents using ggui render interactive views inline, in a locked-down iframe, with the negotiation states labeled (“no host answer” instead of a blank frame).
* **Cold starts are narrated** — “Connecting…” escalates to “Getting ready…”, then “Still getting ready. The first reply can take a little longer.”, driven by the platform’s real turn lifecycle (as of `@guuey/chat` 0.31.0; earlier versions show “Starting your agent…”, then “Starting your agent… first load can take a minute”).
* **Dropped connections recover themselves** — if a stream goes silent while the agent actually finished, the UI adopts the completed reply from persisted history instead of freezing on a cursor.
* **Errors arrive as human copy** — coded platform errors (quota, auth, transient) render family-appropriate wording with an action slot, never a raw code string.
* **A composer that knows the turn state** — Enter sends, Shift+Enter newlines, IME composition is respected, Send becomes Stop mid-turn, and stopping keeps the partial reply marked “Stopped.”
* **Accessibility as a default** — streaming text and status announce via live regions, every collapsible is keyboard-operable, prompts manage focus, animations respect reduced-motion.
* **Scroll behavior that holds** — pinned to the latest message while streaming, released the moment the reader scrolls up, with a jump-to-latest affordance. A generated card that grows taller than the panel settles at its top, with the rest of the reply below it behind jump-to-latest, so the card is never scrolled out of view by its own growth. Long transcripts render windowed.
Everything above is the `calm` preset — the end-user default. Unknown or future content types render as labeled, collapsed rows: **never blank, never raw JSON**.
## Give visitors a stable identity
[Section titled “Give visitors a stable identity”](#give-visitors-a-stable-identity)
The default setup carries identity via cookies, which is best-effort for cross-origin embeds. For durable anonymous continuity, mint a guest secret and hand it to the component — the same [identity model as the underlying SDK](/sdk/#give-visitors-a-stable-identity):
```tsx
"use client";
import { GuueyChat } from "@guuey/chat/react";
import "@guuey/chat/styles.css";
/** 64 lowercase hex chars, minted once and persisted. Returns null if storage is blocked. */
function getGuestSecret(): string | null {
try {
const key = "guuey:guest-secret:your-app-id";
const existing = window.localStorage.getItem(key);
if (existing && /^[0-9a-f]{64}$/.test(existing)) return existing;
const bytes = crypto.getRandomValues(new Uint8Array(32));
const secret = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
window.localStorage.setItem(key, secret);
return secret;
} catch {
return null;
}
}
export function AgentChat() {
return (
);
}
```
`apiBaseUrl` is what turns on the batteries-included read paths: transcript history on reload, and resolution of generative-UI views by read (the component builds a reader over the same identity for you). Signed-in users work the same way as the SDK’s [bring-your-own-auth](/sdk/#signed-in-users-bring-your-own-auth): pass `getAccessToken` instead of `getGuestSecret`. If you construct your own `adapters` or `reader`, they always win over these defaults.
Views resolve by read — bring an identity the read plane can see
Generative-UI views mount by reading their `ui://` locator, and that read is authenticated. A **bearer** or a **guest secret** identifies the read on every environment and from any origin. Pure cookie identity (no `getGuestSecret`, no `getAccessToken`) also works — **but only same-host**: the guest cookie is host-only and `SameSite=Lax`, so it rides along when your page and the API share a host (a custom domain), and is never sent cross-site to the absolute `api..guuey.com` host. Cross-origin embeds — the widget iframe, native apps, a page on your own domain calling the absolute API host — need the guest secret; without it, cookie-only visitors there see views as “expired” (labeled, never blank). When both a cookie and a guest header are present on a same-host read, the cookie decides — the same precedence the chat stream uses.
## First customization
[Section titled “First customization”](#first-customization)
Three dials cover most products, all as props:
```tsx
```
* **`preset`** — `"calm"` (default, end-user) or `"debug"` (everything expanded, wire codes visible — the builder view).
* **`strings`** — every piece of UI copy in one override map, which is also the i18n seam. `composerWaitingOnYou` (the composer’s line while a card waits on the visitor) is a required string: if you compose the lower-level components yourself, add it — or spread `defaultChatStrings` and override what you need.
* **`policy`** — per-category behavior knobs (grouping thresholds, collapse defaults, which tool rows show, error wording, result size caps).
When props stop being enough, the same package exports every layer underneath — replace one component, keep the rest; or take the headless view-model and render everything yourself. That ladder is the next page: [Customization & presets](/chat-customization/).
## Suggested prompts
[Section titled “Suggested prompts”](#suggested-prompts)
Chips (or any host-driven send) stay on the batteries-included path through the imperative handle — a `ref` (or the `onReady` callback, same object):
```tsx
import { useRef } from "react";
import { GuueyChat, type GuueyChatHandle } from "@guuey/chat/react";
function Assistant() {
const chat = useRef(null);
return (
<>
{["What can you do?", "Plan my week"].map((p) => (
))}
>
);
}
```
`send` runs through exactly the Send button’s gate — it returns `false` (and does nothing) while a turn is in flight, when chat is unavailable, or for blank text — and it never touches a half-typed draft. `prefill(text, { append })` and `focusComposer()` ride the same handle for stage-then-edit flows, and `handle.threadId` (or the `onThread` prop) tells you which thread you’re on without wrapping storage. Details: [Customization & presets](/chat-customization/).
## Theming
[Section titled “Theming”](#theming)
`` takes a serializable theme object — see [Theming](/chat-theming/). The default theme is brand-neutral; the guuey look ships as a second exported constant. The kit’s own chrome takes the app theme too, and a `header` prop renders the one head row over a conversation — ``, the first child of `.guuey-chat-surface`, with an optional close control whose label is `strings.headerClose`.
## Strict Content-Security-Policy hosts
[Section titled “Strict Content-Security-Policy hosts”](#strict-content-security-policy-hosts)
If your page ships a strict CSP, two things are worth knowing before the first generated view renders.
**Views need origins your policy may not list.** A generative-UI view boots a runtime bundle and opens a live channel to its MCP host — under a `script-src`/`connect-src` that omits those origins, the frame stays blank and the kit can only say “this view didn’t start”. Two aids:
* Pass the view’s declared origins so the host can name the cause: `"], connectDomains: ["https://", "wss://"] } }}>`. When your **page** loads or connects to one of those origins and your policy refuses it, the `no-handshake` label upgrades to the exact allowance — *“add `script-src-elem https://assets.`”* — and, under `preset="debug"`, the same verdict reaches [`onDebugEvent`](/chat-customization/#the-debug-sink) as `view-phase.diagnosis`. (The upgraded label shows under the default calm preset too; the sink only fires under the debug preset.)
* Know the tripwire’s reach: it listens on **your document**, so it sees violations your page incurs on the view’s origins (a runtime bundle you load at page level, a channel you open on the view’s behalf). A `srcdoc` view’s *own* blocked loads report inside the frame’s opaque origin, where nothing outside can listen — for those, the CSP console message names the blocked URL, and the fix is the same allowance. (Views mounted through a `sandboxPageUrl` relay carry their own policy and are unaffected by yours.)
**One `script-src eval` report is not the view.** `@guuey/chat` reaches zod v4, which probes `new Function("")` once at boot to decide whether to compile fast parsers — under a policy without `'unsafe-eval'` that probe is caught, harmless, and still **reported** as a `securitypolicyviolation` (zod’s own source says so). It is a false positive on exactly the channel the tripwire watches. Silence it by opting zod out of the probe **before its first schema parses** — as a side-effect module that is your entry’s *first* import:
```ts
// csp-jitless.ts — import this FIRST in your entry
import { z } from "zod";
z.config({ jitless: true });
```
main.tsx
```ts
import "./csp-jitless";
import { GuueyChat } from "@guuey/chat/react";
```
A later call runs after the probe and changes nothing.
## Widget or kit?
[Section titled “Widget or kit?”](#widget-or-kit)
The [one-tag widget embed](/embed/) is the no-code path: guuey’s origin, guuey’s updates, zero build. `@guuey/chat` is for when the chat should be **inside your product** — your bundle, your router, your design system. Both render the same transcript tier; you are not trading capability for control.
# Customization & presets
> The @guuey/chat override ladder — presets and policy knobs, component overrides, your own chrome over , and the pure headless tiers underneath.
`@guuey/chat` is built as a ladder. Every rung is a public export, so you customize by **dropping one level down**, never by forking: replace exactly what you need and keep everything else — including future improvements to the parts you didn’t touch.
| Rung | You use | You control |
| ---- | ------------------------------------------------------ | ------------------------------------------------- |
| 1 | `` props | Preset, policy knobs, strings, theme |
| 2 | `components={…}` overrides | Any category’s rendering, rest of the kit intact |
| 3 | `` + `useTranscript`/`useTranscriptInputs` | All chrome: your composer, header, layout |
| 4 | `planTranscript` / `invokeTurn` / history assembler | Everything — the kit becomes a pure data pipeline |
**When to drop down:** stay on rung 1 until a *category of content* needs to look different (rung 2); go to rung 3 when the *surface around the transcript* is yours (most products with an existing design system land here — it’s how guuey’s own widget and console consume the kit); rung 4 is for non-React hosts, server-side rendering, or a fully bespoke renderer.
## Rung 1 — presets, knobs, strings
[Section titled “Rung 1 — presets, knobs, strings”](#rung-1--presets-knobs-strings)
Two presets ship, as policy factories: `calmPolicy()` (the end-user default) and `debugPolicy()` (the builder view). The digest of what `debug` changes:
| Category | `calm` | `debug` |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- |
| Reasoning | Collapsed one-liner | Expanded by default |
| Tool calls | One line, args behind an expander | Expanded, args visible |
| Tool grouping | On (threshold 2) | **Off** — every tool its own row |
| Data results | Height-capped | Taller cap, byte counts always shown |
| Prompts | The ask + actions | + raw event payload expandable |
| Errors | Family copy; pod refusals (`POD_SATURATED`, `DRAINING`) in the pod’s own words | + the wire code and source message, verbatim |
| Status line | Copy only | + literal state and elapsed time |
| Unknown rows | Type name + size | Full pretty-printed payload |
| Protocol tool rows | Hidden (`tool.showToolRows: "non-ggui"`): the ggui handshake / render / consume rows stay out of the transcript; the rendered card and any **failed** ggui row still show | Shown (`"all"`) |
Every knob a preset sets is individually overridable via `policy` — applied on top of the preset:
```tsx
```
`tool.showToolRows` decides which tool calls become rows at all: `"non-ggui"` (the calm default) hides the ggui protocol narration — anything in the `ggui_` namespace, bare or `mcp__ggui__*` — while the rendered card mount and a failed ggui row (a real refusal) always stay; `"all"` shows every tool row; `"none"` shows none. Render-side only — the agent keeps emitting the events. A host that writes a full `TranscriptPolicy` literal instead of overriding a preset must set it; overrides through `calmPolicy()` / `debugPolicy()` need nothing. Under the calm policy the render card stands on its own: no `via ggui render` line beneath it, because that is the same protocol narration. A non-ggui tool that mounts a view keeps its `via ` line; under `debug` neither appears, since the tool’s own row is already on the page.
### Error voice
[Section titled “Error voice”](#error-voice)
Platform errors carry stable codes, and the `error` policy group decides the wording per code — `copyByCode` supplies your own copy, `verbatimCodes` renders the platform’s own message (written to be user-readable) for the codes you list, or `"all"`:
```tsx
```
Precedence per error: your `copyByCode` entry → the verbatim source message (when the code is listed and the message is non-empty) → the built-in family copy. As of `@guuey/chat` 0.18.3, `verbatimCodes` defaults to `["POD_SATURATED", "DRAINING"]`, so a pod’s capacity or draining refusal shows the pod’s own sentence — the concurrent-turn cap and the retry seconds the client cannot know — instead of a generic transient line. A `verbatimCodes` list you pass replaces that default (keep the two codes in it, as above, to keep the pod’s words), `verbatimCodes: []` restores family copy for every code, and the pause-and-retry behaviour is the same whichever wording shows. (`QUOTA_EXCEEDED` is the app’s per-plan generation quota — see [Plans & billing](/plans-and-billing/).)
### Strings
[Section titled “Strings”](#strings)
Every user-visible string lives in one `ChatStrings` map — status copy, composer labels, group headings, degraded-state labels. Override any subset via the `strings` prop; supplying a full translated map is the i18n path. `humanizeToolName` (exported) is the default tool-title humanizer, replaceable via `policy.tool.humanizeTitle`.
### The imperative handle
[Section titled “The imperative handle”](#the-imperative-handle)
Still rung 1: driving the composer programmatically does not require ejecting. `` exposes a `GuueyChatHandle` — via `ref`, or via `onReady(handle)` for wiring-style hosts; both deliver the **same stable object**, valid for the component’s whole life:
```ts
interface GuueyChatHandle {
/** Sends through the Send button's exact gate. `false` = refused
* (in-flight turn, unavailable chat, blank text) — never bypasses,
* never touches the typed draft. */
send(text: string): boolean;
/** Sets the draft. `append: true` joins onto a non-empty draft with a
* space (never clobbers); focuses unless `focus: false`. */
prefill(text: string, opts?: { focus?: boolean; append?: boolean }): void;
focusComposer(): void;
/** The CURRENT persisted thread id (live read, not a snapshot); `null`
* before the first turn is admitted. */
readonly threadId: string | null;
}
```
Suggested-prompt chips are `handle.send(chip)`; stage-then-edit flows are `handle.prefill(text)`. To key your own per-thread state, read `handle.threadId` on demand, or pass `onThread={(id) => …}` — it fires when the id first hydrates and again on each **distinct** change (a new chat), never for `null` and never twice for the same id. Web-only for now — the native tier ships `` without a native `GuueyChat`, so there is no native surface to hang a handle on yet.
## Rung 2 — component overrides
[Section titled “Rung 2 — component overrides”](#rung-2--component-overrides)
Each content category has one override slot; pass a component map and only those categories change:
```tsx
import type { ToolItem } from "@guuey/chat";
import type { TranscriptItemContext } from "@guuey/chat/react";
function MyToolLine({ item }: { item: ToolItem; ctx: TranscriptItemContext }) {
return (
{item.title}
);
}
;
```
The slots: `userMessage`, `text`, `reasoning`, `tool`, `toolGroup`, `dataResult`, `view`, `viewRef`, `media`, `code`, `citations`, `prompt`, `notice`, `error`, `history`, `compaction`, `unknown`, `status`. Each receives a fully-derived display item (state, title, collapse state, preview text) — your component renders, it never re-derives.
The default components are exported too (`DefaultTool`, `DefaultError`, …), so an override can wrap rather than replace.
### The debug sink
[Section titled “The debug sink”](#the-debug-sink)
Under `debugPolicy`, `useTranscript` (and ``) accept an `onDebugEvent` sink — a typed feed of what the kit actually observes, for wiring into your own log surface or devtools:
```ts
import type { ChatDebugEvent } from "@guuey/chat";
const onDebugEvent = (event: ChatDebugEvent) => {
// "view-phase" — a mounted view's handshake transition (incl. "expired")
// "unknown-block" — an unrecognized block type rendered as a labeled row
// "turn-recovered" — a turn adopted from history after a stream stall
console.debug("[chat]", event);
};
useTranscript({ inputs, policy: debugPolicy(), onDebugEvent });
```
Under `calmPolicy` the sink never fires — debug visibility is the debug preset’s job, and end-user surfaces stay quiet by design.
## Rung 3 — your chrome, the kit’s transcript
[Section titled “Rung 3 — your chrome, the kit’s transcript”](#rung-3--your-chrome-the-kits-transcript)
The pattern guuey’s own surfaces use: keep your composer, header, and layout; render the transcript through `` with the two hooks doing the wiring:
```tsx
"use client";
import { useMemo, useState } from "react";
import { createWebAdapters } from "@guuey/agent-client";
import { useAgentInvoke } from "@guuey/agent-client/react";
import { Transcript, useTranscript, useTranscriptInputs } from "@guuey/chat/react";
import { calmPolicy } from "@guuey/chat";
import "@guuey/chat/styles.css";
export function MyChatSurface() {
const adapters = useMemo(() => createWebAdapters(), []);
const invoke = useAgentInvoke({
endpointUrl: "https://your-agent-endpoint",
appId: "your-app-id",
adapters,
preserveBlocks: true, // the transcript renders blocks, not just flat text
});
const { inputs } = useTranscriptInputs(invoke);
const policy = useMemo(() => calmPolicy(), []);
const transcript = useTranscript({ inputs, policy });
const [draft, setDraft] = useState("");
return (
{/* Your composer. `invoke.send`, `invoke.abort`, `invoke.status` are the contract. */}
);
}
```
`useTranscriptInputs` owns the live assembly (the escalation clock, the prompt ledger); `useTranscript` owns renderer state (collapse toggles, view phases, locator resolution) and produces the plan. Both are pure React — no DOM assumptions — which is why the [React Native tier](/chat-native/) shares them unchanged.
### Resolving generative-UI views at rung 3
[Section titled “Resolving generative-UI views at rung 3”](#resolving-generative-ui-views-at-rung-3)
Generative-UI cards arrive as `ui://` locators, and something has to turn a locator into mount material: a **`UiResourceReader`**. `` builds one for you from `apiBaseUrl`; a rung-3 host wires its own and passes it to `useTranscript` as `reader`. Without one, locators render as “expired” (labeled, never blank). `createUiResourceReader` from `@guuey/agent-client` is the shipped reader — it tries the pod door first (live-turn cards) and falls through to the persisted read plane. The thread id hydrates after the first turn, so build the reader once and let it read the live id:
```tsx
import { useMemo, useRef } from "react";
import { createUiResourceReader } from "@guuey/agent-client";
import type { UiResourceReader } from "@guuey/mcp-apps-host";
// inside MyChatSurface, after `invoke`:
const threadIdRef = useRef(invoke.threadId);
threadIdRef.current = invoke.threadId;
const reader = useMemo(
() => async (resourceUri) => {
const threadId = threadIdRef.current;
if (threadId === null) return undefined; // nothing to scope the read to yet
return createUiResourceReader({
apiBaseUrl: "https://api.us-east-1.guuey.com/v1",
threadId,
endpointUrl: "https://your-agent-endpoint", // enables the pod door
// getAccessToken / guestSecret: the same identity your transport uses
})(resourceUri);
},
[]
);
const transcript = useTranscript({ inputs, policy, reader });
```
Identity rule, in one clause: a bearer or a guest secret identifies the read from any origin; cookie identity alone only works same-host (custom domain) — see the [quickstart’s identity note](/chat/#give-visitors-a-stable-identity).
### Restyling from outside a shadow root (`::part()`)
[Section titled “Restyling from outside a shadow root (::part())”](#restyling-from-outside-a-shadow-root-part)
When you mount `` inside a **shadow root** of your own, the page’s stylesheet cannot reach the kit’s classes — by design. What it can reach is a closed set of **CSS Shadow Parts**, stamped as `part` attributes on the kit’s structural elements and exported as `PARTS`:
| Part | Element |
| -------------------------------------------------------------- | ----------------------------------------------------- |
| `surface` | the outer surface (the theme is stamped here) |
| `header` | the header row — present only when `header` is given |
| `transcript` | the transcript root |
| `message`, `message user`, `message agent` | every message; the visitor’s bubble; the agent’s text |
| `composer`, `composer-input`, `composer-send`, `composer-stop` | the built-in composer form, its textarea, Send, Stop |
| `chips`, `chip` | the suggestion row and each chip |
```css
/* On the shadow host element */
my-chat-host::part(message user) {
background: #0b5cff;
color: #fff;
}
my-chat-host::part(header) {
display: none;
}
```
The table is closed and pinned by a test: names are added, never renamed. Everything without a `part` is internal and may move. Light-DOM mounts keep styling by class name — `part` is a hook for the shadow-root case, never a selector the kit reads. Pair it with `surface="bare"` when the transcript should sit directly on the page’s own background.
## Rung 4 — the pure tiers
[Section titled “Rung 4 — the pure tiers”](#rung-4--the-pure-tiers)
The root subpath (`@guuey/chat`) is React-free forever:
* **`planTranscript(inputs, policy, overrides?)`** — the entire rendering decision as a pure function: folded blocks + turn status in, an ordered list of display items out. Deterministic, stable keys across streaming updates. A custom renderer (or a test) consumes this directly.
* **`transcriptInputsFromHistory(load)`** — build inputs from a persisted-thread read with no hook and no DOM: the server-side rendering seam.
```ts
import { calmPolicy, planTranscript, transcriptInputsFromHistory } from "@guuey/chat";
const inputs = transcriptInputsFromHistory(await loadThread()); // your history read
const plan = planTranscript(inputs, calmPolicy());
// plan.items — render anywhere.
```
* Driving a live turn without React is the underlying SDK’s `invokeTurn` — see [Build your own surface](/sdk/#beyond-react-on-the-web).
* Mounting generative-UI views without the kit is `attachViewHost` from `@guuey/mcp-apps-host` (or `` from `@guuey/mcp-apps-host/react`) — the same primitives the kit’s `view` slot uses.
# React Native
> @guuey/chat/native renders the same transcript with React Native primitives — inverted list, same theme tokens; one required override: the view mount.
`@guuey/chat/native` is the React Native renderer over the **same** view-model as the web kit — the plan derivation, policies, presets, strings, and even the React hooks (`useTranscript`, `useTranscriptInputs`) are literally the same modules; only the walk differs. Guuey’s own Portal apps (iOS/Android) render their chat through this tier.
Both `react` and `react-native` are optional peer dependencies — the root `@guuey/chat` subpath stays importable everywhere; this arm loads only inside an RN runtime. The package ships TypeScript source behind a `react-native` export condition, so Metro transpiles it with your app’s Babel config — no bundler setup.
## Basic usage
[Section titled “Basic usage”](#basic-usage)
```tsx
import { useMemo } from "react";
import { View, Text } from "react-native";
import { createWebAdapters } from "@guuey/agent-client";
import { useAgentInvoke } from "@guuey/agent-client/react";
import {
NativeTranscript,
useTranscript,
useTranscriptInputs,
type NativeTranscriptItemContext,
} from "@guuey/chat/native";
import { calmPolicy, type ViewMountItem } from "@guuey/chat";
// The one required override on native: the kit ships no WebView dependency.
function MyViewCard({ item }: { item: ViewMountItem; ctx: NativeTranscriptItemContext }) {
return (
{item.toolTitle ?? "Generated view"}
{/* mount the item's material in your WebView here */}
);
}
export function NativeChatScreen() {
const adapters = useMemo(() => createWebAdapters(), []);
const invoke = useAgentInvoke({
endpointUrl: "https://your-agent-endpoint",
appId: "your-app-id",
adapters,
preserveBlocks: true,
});
const { inputs } = useTranscriptInputs(invoke);
const policy = useMemo(() => calmPolicy(), []);
const transcript = useTranscript({ inputs, policy });
return (
);
}
```
On RN you supply your own adapters in production (an AsyncStorage-backed thread store and a streaming `fetch` transport) in place of `createWebAdapters` — see the SDK’s [React Native notes](/sdk/#beyond-react-on-the-web).
## The scroll contract, inverted
[Section titled “The scroll contract, inverted”](#the-scroll-contract-inverted)
`NativeTranscript` renders an **inverted list** — the chat-app native idiom where content grows at the scroll origin. That makes the transcript scroll contract hold *by construction*: streaming stays pinned to the latest message, scrolling up never yanks the reader, and a reduce-motion-aware jump-to-latest affordance appears when you’re away from the bottom. Windowing comes from the list primitive itself. Host chrome — keyboard avoidance, docks, insets — passes through a `listProps` escape hatch rather than being guessed at by the kit.
## The view mount is a required override
[Section titled “The view mount is a required override”](#the-view-mount-is-a-required-override)
The native tier deliberately ships **without a WebView dependency** — adding one would force a native module choice on every consumer. So the `view` slot (generative-UI card mounts) is a required override on native: without one, the kit renders a labeled placeholder (“view unavailable”-style copy) — never a blank. Wrap your app’s WebView of choice as the `view` component, as in the sample above; the display item hands you the resolved mount material and the producing tool’s title.
## Theming
[Section titled “Theming”](#theming)
Same token schema as the web — [Theming](/chat-theming/) — projected to RN style values via `resolveNativeTheme` instead of CSS variables. `NativeTranscript` accepts the same `theme`/`mode` props; no stylesheet import exists or is needed on native.
## Web and native together
[Section titled “Web and native together”](#web-and-native-together)
Because the view-model and hooks are shared, a product with both a web and an RN surface writes its transcript logic once: same policy, same strings, same overrides model — `components={{ tool: … }}` on web takes a DOM component, on native an RN one, and everything upstream of the renderer is identical.
# Theming
> The @guuey/chat theme is a serializable token object — one schema projected to CSS variables on web and style values on native, with per-token fallbacks.
A `@guuey/chat` theme is **data, not CSS** — a serializable token object you can keep in code, in config, or in a database. The React kit projects it as `--guuey-chat-*` CSS custom properties on the transcript root; the [React Native tier](/chat-native/) maps the same object to style values. One schema, two projections.
## The schema
[Section titled “The schema”](#the-schema)
```ts
interface GuueyChatTheme {
name: string;
/** Default appearance. Optional — your own mounts pass `mode`; hosted surfaces use it when the host announces nothing. */
mode?: "light" | "dark";
/** BOTH palettes always present — which one paints is a runtime choice, never a second theme object. */
colors: { light: GuueyChatPalette; dark: GuueyChatPalette };
typography: {
fontFamily?: string;
monoFontFamily?: string;
headingFontFamily?: string; // display face; falls back to fontFamily
scale?: number; // one size knob — generated cards derive their eight stops from it
faces?: GuueyChatFace[]; // declared web fonts — the widget injects their @font-face rules and hands them to the cards
};
shape: {
radius: "none" | "soft" | "round";
density: "compact" | "comfortable"; // the widget's own spacing knob — never reaches cards
shadow?: { color?: string; intensity?: number }; // intensity 0–1
glass?: { opacity: number; blur?: number }; // chrome translucency — FROSTED IS THE DEFAULT (0.82 / 14px); state { opacity: 1 } to opt out; the widget's chrome only, never cards
};
/** The four ggui token members guuey writes for generated cards (ggui#1093 R1). All optional and default-less: unstated = ggui derives it, byte-identical to a document that never had the key. None of them touches the widget's own chrome. */
typeScale?: {
display?: GuueyChatTypeRole; // the roles ggui projects to its --ggui-font-* stops
h1?: GuueyChatTypeRole;
h2?: GuueyChatTypeRole;
body?: GuueyChatTypeRole; // ONE WRITER: derived from typography.scale when only the scale is stated; a document stating BOTH scale and body.size is refused
label?: GuueyChatTypeRole;
};
rhythm?: { base: string; section?: string; inset?: string }; // CSS lengths — base re-derives the cards' spacing scale; section / inset are named steps
motion?: {
duration?: { fast?: string; base?: string; slow?: string }; // CSS times — a tempo override over ggui's shipped scale, never a new scale
easing?: { standard?: string; emphasized?: string; exit?: string }; // CSS easing functions
};
scrim?: { tone: "light" | "dark"; opacity: number; blur: number }; // the three knobs together — opacity 0–1, blur in px; the scrim colours derive from the ground pair
/** Per-court overrides, resolved over the base theme — advanced. */
courts?: Record;
}
interface GuueyChatTypeRole {
size?: string; // CSS length
weight?: number; // 100–900
tracking?: string; // CSS length
leading?: number; // unitless line-height
}
interface GuueyChatPalette {
accent: string; // primary action + user bubble
onAccent: string; // text on accent
ink: string; // primary text
inkMuted: string; // secondary text
surface: string; // bubbles, cards
canvas: string; // page background behind the transcript
canvasMuted: string; // grouped/collapsed rows, wells
error: string;
link?: string; // anchors; unstated = derived from accent on cards
secondaryAccent?: string; // a second brand colour for cards' tertiary actions
onSecondaryAccent?: string; // text on the secondary accent
success?: string; // tone anchors — a family exists on cards only once stated
warning?: string;
info?: string;
}
interface GuueyChatFace {
family: string;
src: string; // https:// on Google Fonts, Adobe Fonts, Fontshare, Bunny Fonts, or one of the app's allowed domains
weight?: string; // e.g. "400 700"
style?: string;
display?: string;
}
```
Both palettes are always present in a theme; which one paints is the `mode` prop (`"light"` / `"dark"`) on your own mounts — so switching modes is a runtime choice, never a second theme object. The theme’s own `mode` is its default appearance: on hosted surfaces it applies when the host announces nothing, and a host’s explicit `theme` outranks it (see **Precedence** below).
**Frosted glass is the default.** An app that states no `glass` paints guuey.com’s own look: the panel and ask bar are a translucent canvas (`opacity` 0.82) with a 14px backdrop blur and a soft shadow (`shape.shadow` `0e1014` at 0.22), and the transcript frame runs transparent so the host page shows through; bubbles and cards stay solid. A theme inherits it unless it states `glass: { opacity: 1 }` (opaque) — in the file, the console’s Glass control, or a “Start from” document (“Opaque (no glass)”). One residual: the frame’s transparency depends on the host’s `color-scheme` matching the widget’s effective mode — the widget states its own on both sides, and a host page that declares `color-scheme: dark` around a light-mode widget may show an opaque panel canvas instead of the through-blur (the tint still paints).
**What reaches where today.** The palette tokens paint both the widget’s own chrome and the generated cards inside it. A save always lands on the widget; when the generated cards’ side does not follow it — ggui refuses an overlay that would drop members it holds that guuey does not write, or a cleared theme is kept there until a member-level clear exists — the console’s Design page and the CLI say so beside the save, naming the members, instead of a bare “Saved”. `typography.fontFamily` and `monoFontFamily` reach both. `typography.fontFamily` also sets the widget’s own chrome — header, composer, buttons — not only the transcript. `typography.scale` and `shape.radius` style the widget’s chrome and reach generated cards as well — the scale as the base of the cards’ font ramp, the radius as a five-stop ladder and, in the widget, as the transcript’s bubble radius (0 / 10 / 18px) and the expanded layout’s panel radius (`none` 0, `soft` 16px, `round` 24px); `shape.glass.blur` is the blur behind those panels as well as behind the host-page panel; `shape.density` is the widget’s own spacing knob and never reaches a card. Generated cards derive their colour ladders — hover and pressed steps, tints, container pairs — from the palette anchors (`accent`, `error`, and the optional `secondaryAccent` / `success` / `warning` / `info`); there is no ladder to state. `link` paints anchors on generated cards; unstated, the cards derive it from the accent. `typeScale`, `rhythm`, `motion` and `scrim` are the cards’ side only: stated, they travel with the theme into the projection that builds each generated card’s variables — the type roles to the cards’ font stops, `rhythm.base` re-deriving their spacing scale, `motion` as a tempo override over ggui’s shipped motion scale, `scrim` as three knobs whose colours derive from the ground pair — and the widget’s own chrome does not read them. Unstated, ggui derives every one, byte-identical to a document that never had the key, so a theme written before these members existed renders unchanged. The body size has **one writer**: `typography.scale` already feeds the cards’ font ramp, so when only the scale is stated `typeScale.body.size` is derived from it, and a document stating both is refused at apply time rather than letting one silently override the other. `typography.faces` declares web fonts to load: the widget injects each face’s `@font-face` rule for its own chrome and transcript, and the faces travel with the theme to the generated cards’ side. A face must be an `https` font file (`.woff2`, not a stylesheet) on `fonts.gstatic.com`, `fonts.googleapis.com` or one of your app’s own allowed domains; anything else is refused when you save. A face that fails to load in the browser falls back down your `fontFamily` stack, so state fallbacks you are happy with. `courts` are per-court overrides resolved over the base theme: stored with the document and read by the live widget; no console or CLI reader shows them yet. `shape.glass` is the widget’s chrome only: the panel, bar and cold-open shell on the host page and the frame’s own chrome strips turn translucent over the page at that opacity, with the backdrop blurred; cards and bubbles stay solid, and a standalone page (nothing behind it) is never glass. Both palettes reach an app’s hosted cards: the projection derives one overlay per mode and stamps both, with the theme’s default `mode`.
The derived ladders run in opposite directions per mode: in **light** mode hover and pressed are darker than the anchor; in **dark** mode they are lighter. You state one anchor per palette and the direction is handled for you. The pre-revision `ramps` member has left the vocabulary, and the two doors treat a stray one differently: a manifest `theme.json` (the file `guuey.json` names under `app.theme.file`) that still states it is **refused** by the CLI’s strict manifest schema — move the values to anchors; a chat-kit theme document (`--chat-theme-file`, or the stored `chatTheme`) that still carries it is accepted and the key is ignored — the anchors are what paint either way.
## The two shipped themes
[Section titled “The two shipped themes”](#the-two-shipped-themes)
* **`DEFAULT_CHAT_THEME`** — brand-neutral and polished; what you get without configuring anything. Designed to sit inside *your* product without announcing guuey.
* **`GUUEY_CHAT_THEME`** — the guuey visual identity (the widget’s shipped palette). Use it when you want the surface to read as guuey.
```tsx
import { GUUEY_CHAT_THEME } from "@guuey/chat";
;
```
## Your own theme
[Section titled “Your own theme”](#your-own-theme)
`resolveTheme` merges a partial theme over the default **per token** — supply only what you change:
```tsx
import { resolveTheme, type GuueyChatTheme } from "@guuey/chat";
const brandTheme: GuueyChatTheme = resolveTheme({
name: "acme",
colors: {
light: { accent: "#7c3aed", onAccent: "#ffffff" },
dark: { accent: "#a78bfa" },
},
shape: { radius: "round", density: "compact" },
});
;
```
Every token you omit falls back to the default theme’s value — a half-configured theme can never produce an unreadable surface.
## The evolution promise
[Section titled “The evolution promise”](#the-evolution-promise)
The schema is built to be **stored**: parsing is lenient (unknown keys pass through rather than failing), changes to the schema are additive-only, and every new token ships with a default fallback — so a theme object saved today keeps parsing against every future version of the package.
That posture is what makes the theme safe to store as platform data — which is exactly what per-app theme configuration does.
## Theme the hosted surfaces
[Section titled “Theme the hosted surfaces”](#theme-the-hosted-surfaces)
You can set a theme **once per app** in the console: your app’s **Design** page on platform.guuey.com has a theme editor over this same schema, with a live transcript preview, validated on save. Every surface guuey hosts for the app picks it up — the embedded widget, the app’s standalone page, and Portal chat — with no redeploy.
The editor lives on the app’s Design page in the console; branding and the distribution tabs are covered on the same docs page — [Design & distribution](/console-design/).
Managing the app from its repo with [agents-as-code](/agents-as-code/)? Declare the same theme in `guuey.json` under `app.theme` — either the document inline, or a file reference:
```json
"app": { "theme": { "file": "theme.json" } }
```
`theme.json` carries the theme document itself (`mode`, both `colors` palettes, and optional `typography` / `shape` / `typeScale` / `rhythm` / `motion` / `scrim`). This tier is strict: lengths are `px` / `rem` / `em`, weights 100–900, leading 0.8–2.5, durations `ms` / `s`, easings a CSS keyword or `cubic-bezier()` / `steps()`, scrim opacity 0–1 and blur 0–64, and an unknown key is refused. The CLI resolves the path relative to `guuey.json` at apply time and submits the resolved document — the platform validates colour values on the resolved bytes, and `guuey agent apply` converges the stored theme like any other declared field.
Whichever door you use, the platform validates the document before it lands: `name` at most 64 characters; `typography.scale` between 0.75 and 1.5; font strings at most 200 characters; colours in one grammar — lowercase `#rrggbb` or `#rrggbbaa`, or `rgb(r, g, b)` / `rgba(r, g, b, a)` — no three-digit shorthand, no colour names, no `hsl()`, `var()` or `calc()`; the whole document under 16 KiB. A rejected field is named in the error, so you fix that one token.
From the CLI, `guuey apps update --chat-theme-file theme.json` sets the same theme on a deployed app without a repo binding, and `--clear-chat-theme` removes it. Every door writes the same document: the console’s Design → Chat theme editor; the CLI flag; the [platform MCP server](/platform-mcp/)’s `apps_update` tool (`{ "id", "chatTheme" }` — for a coding agent that has the Guuey platform server connected); `app.theme` in `guuey.json` — converged by `guuey agent apply` for declarative apps, and applied by `guuey deploy` right after the build goes live **when the app has no chat theme yet** (the same `PUT` as the CLI flag; a deploy never overwrites an existing theme — the console’s or an earlier write — it prints the doors that do; a refused document prints the field and the door, never a failed deploy); the `theme` prop on `@guuey/chat` surfaces you render yourself; and the coding-agent prompt below, which ends in one of those.
**Precedence.** On every surface guuey paints for an app — the widget panel and its launcher chrome, the standalone page, generated cards, the share-image card, and the console’s Embed preview — the chat theme (resolved over the kit default, for the effective mode) wins over `brandAccent` (Settings → General: accent only, on the guuey default palette), which wins over the guuey default; the effective mode is the host’s explicit `theme` (light or dark), else the theme’s declared `mode`, else light.
Your own `@guuey/chat` mounts are unaffected: the `theme` prop you pass always wins on surfaces you render yourself.
#### Give this to your coding agent
[Section titled “Give this to your coding agent”](#give-this-to-your-coding-agent)
Copiable, for Claude Code, Codex or Cursor. It asks only for what this page and the Embed page describe.
Paste into your coding agent
```text
Theme the Guuey chat widget to match this site. Before anything, read
https://docs.guuey.com/chat-theming.md and
https://docs.guuey.com/embed.md (section "Customize the launcher") and stay
inside what they describe — use only the theme fields the schema names.
1. Read this site's styles and pick its palette (light and dark if it has
both), font family, corner radius and density.
2. Write a theme document as theme.json: "name", "mode", both "colors" palettes
("link", "secondaryAccent", "success", "warning" and "info" are optional
anchors), "typography" (optionally "headingFontFamily" and "faces"), and
"shape" (optionally "shadow" and "glass"; frosted glass is the default —
omit "glass" to keep it, state { "opacity": 1 } to opt out) — nothing outside the schema; leave "courts"
alone.
3. If this repo manages the Guuey app as code (guuey.json with an "app"
section), set "app": { "theme": { "file": "theme.json" } } and tell me to
run guuey agent apply. Otherwise tell me to run
guuey apps update --chat-theme-file theme.json (or the platform MCP
server's apps_update tool, if it is connected to you), or hand me the JSON
to paste into the console's Design → Chat theme editor.
4. The launcher bubble is styled by the embed snippet, not the theme: propose
values for the init options color, iconColor, shape, position and theme
(light or dark) that match the site, and show me the updated snippet.
Stop and ask me before any step those two pages do not describe.
```
## Fine-grained control
[Section titled “Fine-grained control”](#fine-grained-control)
The projection is ordinary CSS custom properties, so anything the token schema doesn’t express can be adjusted in your own stylesheet against the kit’s class names — or by replacing a [component slot](/chat-customization/#rung-2--component-overrides) entirely. Prefer tokens first: they’re what survives schema evolution, and what the console’s per-app theme stores.
# CLI
> Install the Guuey CLI, log in, define your agent in guuey.json, and deploy it to Guuey hosting.
The `guuey` CLI is the code path onto the platform: it scaffolds an agent project, runs it locally, and deploys it to Guuey hosting.
Note
Guuey is in public beta preview. The CLI is published and installable today, but you need a Guuey account to log in and deploy.
## Install
[Section titled “Install”](#install)
The CLI ships on npm as [`@guuey/cli`](https://www.npmjs.com/package/@guuey/cli):
```bash
npm install -g @guuey/cli
guuey --version
```
The CLI talks to Guuey’s production API out of the box — no endpoint configuration needed. To point it somewhere else, set `GUUEY_API_URL` (keep the `/v1` suffix — route paths are appended to it); the environment variable always wins over the built-in default.
## Log in
[Section titled “Log in”](#log-in)
```bash
guuey login
```
This opens your browser, authenticates you with your Guuey account, and delivers an API key back to the CLI via a localhost callback. On a remote or SSH session, `guuey login --no-browser` prints the auth URL instead — open it anywhere and paste the token back at the prompt. For headless or CI use, pass a pre-minted API key:
```bash
guuey login --token guuey_user_...
```
Check who you are with `guuey whoami`; clear credentials with `guuey logout`.
## The agent definition: guuey.json
[Section titled “The agent definition: guuey.json”](#the-agent-definition-guueyjson)
Every project is described by a single `guuey.json` at the project root. The `agent` section is the deployable definition — framework, model, system prompt, and MCP servers. A minimal valid file:
```json
{
"schema": "1",
"agent": {
"framework": "claude-agent-sdk",
"model": "claude-sonnet-5",
"systemPrompt": { "file": "prompts/system.md" }
}
}
```
Everything else defaults. Notably, `agent.mcpServers` defaults to the `ggui` server at `https://mcp.ggui.ai`, which gives your agent generative UI out of the box — see the [ggui docs](https://docs.ggui.ai) for what that enables. Servers you declare merge on top of the default; the platform is otherwise MCP-server-agnostic.
`guuey.json` MCP servers with `credential: 'oauth'` accept **`authMode: 'upfront' | 'lazy'`**. **`'upfront'`** schedules the broker’s sign-in before the first turn: the agent’s first ask carries `metadata.authMode = "upfront"`, and `@guuey/chat` renders the connect-first step instead of an aside mid-conversation. **Absent = today’s on-demand behaviour** — an existing embedder sees no change unless its app’s builder opts a server in. Whatever the mode, the model reads each OAuth server’s state for the turn in the briefing’s **Connected services** section — see [What wraps your prompt](/prompt-wrapping/#connected-services).
### Choosing a model
[Section titled “Choosing a model”](#choosing-a-model)
`agent.model` takes a provider model id. Which vendor’s ids apply follows the framework your agent runs on: the Claude Agent SDK runs Anthropic models, the OpenAI Agents SDK runs OpenAI models, and Google ADK runs Gemini models. Every list below is checked against the platform’s model registry on every docs build, so an id you read here is one the registry lists — and a row marked **announced** is in the registry but not yet serving on Guuey; the console’s model rack shows it the same way.
**Anthropic** (Claude Agent SDK)
* **Claude Fable 5.1** (`claude-fable-5-1`) — Anthropic’s model for the most demanding reasoning (adaptive thinking is always on); in Guuey’s Anthropic lineup.
* **Claude Opus 5.5** (`claude-opus-5-5`) — Anthropic’s newest Opus; in Guuey’s Anthropic lineup.
* **Claude Sonnet 5** (`claude-sonnet-5`) — Guuey’s **default**, and what the scaffold writes: the cost-per-quality call for a hosted agent that runs all day.
* **Claude Haiku 4.5** (`claude-haiku-4-5`) — the cheapest of the family; also the earliest scheduled retirement in it (not before October 15, 2026).
* **Claude Fable 5** (`claude-fable-5`) — **legacy** in Anthropic’s lineup but still active and available (retirement not before June 9, 2027) — the top of the lineup you can run on Guuey today.
* Also available, behind **See all models** in the console: **Claude Opus 5** (`claude-opus-5`), **Claude Sonnet 4.6** (`claude-sonnet-4-6`), **Claude Opus 4.8** (`claude-opus-4-8`).
**OpenAI** (OpenAI Agents SDK)
* **GPT-6 Luna** (`gpt-6-luna`) — Guuey’s **default** for OpenAI-framework agents, and what the scaffold writes.
* **GPT-5.6 Terra** (`gpt-5.6-terra`) — in Guuey’s OpenAI lineup, served through the managed key.
* **GPT-6 Sol** (`gpt-6-sol`) — in the lineup, served through the managed key.
* **GPT-6 Astra** (`gpt-6-astra`) — in the lineup, served through the managed key.
* Also available, behind **See all models** in the console: **GPT-5.6 Sol** (`gpt-5.6-sol`), **GPT-5.6 Luna** (`gpt-5.6-luna`), **GPT-5.5** (`gpt-5.5`), **GPT-5.4** (`gpt-5.4`), **GPT-4o** (`gpt-4o`), **GPT-4o Mini** (`gpt-4o-mini`).
**Google** (Google ADK)
* **Gemini 3.8 Flash** (`gemini-3.8-flash`) — in Guuey’s Google lineup, served through the managed key.
* **Gemini 3.6 Flash** (`gemini-3.6-flash`) — Guuey’s **default** for Google-framework agents, and what the scaffold writes.
* **Gemini 3.5 Flash Lite** (`gemini-3.5-flash-lite`) — in the lineup.
* Also available: **Gemini 3.5 Flash** (`gemini-3.5-flash`), **Gemini 3.1 Pro** (`gemini-3.1-pro`), **Gemini 2.5 Flash** (`gemini-2.5-flash`), **Gemini 2.5 Pro** (`gemini-2.5-pro`).
On the Google ADK facet, `outputTokens` now counts every generated token including reasoning (Gemini’s `candidatesTokenCount + thoughtsTokenCount`, guarded against double-counting where the provider’s total already balances on candidates); `reasoningTokens` carries the thoughts breakdown separately. Exception: `@google/adk`’s Interactions-API route reports no thoughts, so there `outputTokens` stays exclusive and `reasoningTokens` is absent (upstream gap). Re-baseline any per-token budget you set against the new count.
On the Claude facet, two usage changes arrive together. First, `inputTokens` now counts every input token the provider processed: the uncached input plus cache reads plus cache writes. Before, it counted the uncached input only. This applies to every usage entry, and `cacheReadTokens` and `cacheWriteTokens` still carry the cache part on its own. Second, `turn.done.usage` is now flagged `cumulative: false` (it was `true`), because its token counters cover that turn alone. Its `costUsd` is still the running cost of the agent’s whole query, which spans turns, so it carries `costScope: "query"` whenever a cost is reported: take the latest figure, and never add turns’ `costUsd` together. The per-model `byModel` entries remain running totals (`cumulative: true`). OpenAI and Google ADK agents on Guuey report the same numbers as before. The same release changes the ADK facet only on the Gemini Live API (there, `totalTokens` becomes input plus output plus tool-use input, and `totalTokensRaw` carries Google’s own total where it differs), and Guuey’s Gemini models don’t run on the Live API. Hosted agents report the new values once they run the new runtime: a no-code app when the platform rolls it forward (a pinned one on its next deploy), and a code-mode app on its next deploy, because its image carries the runtime it was built on (every `guuey deploy` queues a new build, even for an unchanged project; see [runtime updates](/concepts-hosting/#runtime-updates)). Under `guuey dev`, they arrive with `@guuey/cli` 0.29.0. Guuey’s own metering reads the provider’s raw usage, so what you’re billed doesn’t change. Re-baseline any per-token budget you set against Claude’s `inputTokens`.
Managed-model usage is metered per [Plans & billing](/plans-and-billing/); each vendor’s list prices are on its own pricing page (Anthropic’s is [here](https://www.anthropic.com/pricing)).
**Long-context pricing:** OpenAI prices a prompt with more than 272K input tokens at 2× input and cache rates and 1.5× output, for the full request, on the models that accept one: GPT-6 Sol, Astra and Luna, GPT-5.6 Terra, Sol and Luna, GPT-5.5 and GPT-5.4. Google’s Pro models (Gemini 3.1 Pro, Gemini 2.5 Pro) have their own higher price above 200K prompt tokens. Guuey’s usage meter applies each vendor’s tier to the whole request, the way the vendor bills it.
One thing to know if you switch a code-mode agent to Fable 5.1: it rejects non-default sampling parameters (`temperature`, `top_p`, `top_k`), forced `tool_choice`, and manual thinking budgets — adaptive thinking is always on. Guuey’s own runners send none of those; if your worker code sets them, drop them first.
Create the file in an existing directory with:
```bash
guuey config init
```
On a code-mode project, your first `guuey deploy` from an interactive terminal asks who the agent is for — *Is this agent for you, or for your customers?*, `1` Just for me or `2` For my customers — then for an app name, creates the app, and writes its `appId` and your answer (as `app.builtFor`) into the file — you don’t write either by hand. When `guuey.json` already declares `app.builtFor`, the deploy prints `Built for: … (from guuey.json)` and skips the question. (The question is as of `@guuey/cli` 0.27.0; what `app.builtFor` means is in [Agents as code](/agents-as-code/).) That auto-create is TTY-only and code-mode-only: in CI or any non-interactive run, and for a declarative project, bind the app first — pass `--app-id `, run `guuey pull --app-id ` to bind an existing app, or create one with `guuey apps create --name "My App"` (add `--for personal` or `--for customers`; without it the app reads as customers). (`workspaceId` is only needed when you deploy hosted MCP servers, supplied via `guuey.json`, `--workspace`, or `$GUUEY_WORKSPACE`.) Secrets never go in `guuey.json`; use `guuey env set KEY=VALUE`. As of `@guuey/cli` 0.19.0 every deploy names its target and where it came from — `Deploying to app (from --app-id | GUUEY_APP_ID | guuey.json | global-config).` — in that precedence: `--app-id` wins for that deploy only, then the `GUUEY_APP_ID` environment variable, then the `guuey.json` binding, then the global default in `~/.guuey/config.json` (used only when there is no `guuey.json` at all). A project whose `guuey.json` carries no `appId` is **unbound**: `GUUEY_APP_ID` is ignored there (`GUUEY_APP_ID= ignored: this project has no binding of its own — pass --app-id to target it. Creating a new app instead.`) and the global default is deliberately not used — an interactive deploy of a code-mode project offers to create and link a new app; a non-interactive run — or a declarative project, whatever the terminal — stops with `No app linked`, naming the ways out (`--app-id `, `guuey pull --app-id `, or an interactive code-mode deploy).
## Create a project
[Section titled “Create a project”](#create-a-project)
Start from a working scaffold:
```bash
guuey create my-agent --framework claude-agent-sdk
```
Supported frameworks for `create` are `claude-agent-sdk` and `openai-agents-sdk`. Add `--for personal` (just for you) or `--for customers` to record who the agent is for as `app.builtFor` in `guuey.json`; the first `guuey deploy` sends it when it creates the app, and without it that deploy asks. You can also scaffold without installing the CLI first:
```bash
npx @guuey/create-agentic-app my-agent
```
The scaffold carries **zero analytics bytes** by default; `--analytics posthog` opts a project into PostHog, reading `VITE_POSTHOG_KEY` (and an optional `VITE_POSTHOG_HOST`) at build time — nothing is sent without that key. The Google ADK template scaffolds `@google/adk ^2.0.0`. After scaffolding, `pnpm bootstrap` writes the brand, copy and the chat theme document locally; `pnpm bootstrap -- --link` pushes them to the platform — the allowed origins you name, then the brand accent, then the chat theme (`theme.json`, kept in step with `guuey.app.json`’s `mode` and accent); each push prints `✓` or a named refusal, and the run keeps going. See [Your first agentic app](/tutorial-first-app/#9-bind-the-app-into-the-frontend).
Prefer a guided walkthrough? [Your first agentic app](/tutorial-first-app/) takes the scaffold from `npx` to a deployed agent step by step.
Built your agent without code? `guuey pull --app-id ` brings its live definition into a local `guuey.json`, externalizing the system prompt to `prompts/system.md` so the project stays editable. Check that into your repo and it round-trips losslessly through `guuey deploy` — or manage it with `guuey agent apply` from then on (see [Agents as code](/agents-as-code/)).
## Deploy
[Section titled “Deploy”](#deploy)
```bash
guuey deploy
```
`deploy` auto-detects which of two modes your project uses:
* **Declarative mode** — `guuey.json` only, no build step. Your agent is the definition: prompt, model, MCP servers.
* **Code mode** — builds and deploys your `guuey.worker.js` worker bundle (or uses a root `Dockerfile` if present), deploying your MCP servers, generative UI registration, and the agent itself in one command.
In code mode, the worker bundle implements the Guuey Worker Protocol v1 — [`@guuey/worker`](https://www.npmjs.com/package/@guuey/worker) on npm ships the contract types and the tiered worker SDK (typed interpreter + serve loop), and `guuey worker verify` checks a built `guuey.worker.js` for conformance before you deploy. The `create-agentic-app` scaffold above is a working code-mode starting point.
When nothing binds an app, `deploy` stops with a remedy for the mode you are in — set `appId` in `guuey.json`, pass `--app-id `, or `guuey pull --app-id ` to bind an existing app; in code mode it adds “run `guuey deploy` in an interactive terminal to create one”, in declarative mode “`guuey apps create --name ` and put its id in `guuey.json`”. An app that `deploy` creates carries the manifest’s framework and model, and who it is for (`app.builtFor`, declared in `guuey.json` or answered at the prompt). After `Live at `, a one-time `Look:` line prints when the app renders the Guuey theme (no chat theme of its own), naming the two write paths — the console’s Design → Chat theme, or `app.theme` in `guuey.json`, which `guuey deploy` (code apps) and `guuey agent apply` both apply; it never prints when the app has a theme. When `guuey.json` carries `app.theme` and the app has no chat theme yet, `deploy` writes it right after `Live at` and prints a `Theme:` line; an app that already has a theme is never overwritten by a deploy — the line names `guuey apps update --chat-theme-file` and the console as the doors that change it (a refused document prints the field and the door; the deploy itself still succeeds).
Force a mode with `--declarative` or `--code`. Pick a runtime pod size with `--size` (`xs` | `sm` | `md` | `lg` | `xl`, default `xs` — see [Plans & billing](/plans-and-billing/) for what each size buys) and tag the version with `--label`. Code-mode builds run in a separate build job sized by `--build-size` (`sm` | `md` | `lg` | `xl`, default `md`). In code mode the deploy first checks for `node_modules` at the project root; without it the deploy installs for you (`corepack pnpm install`, before anything is pushed) and then builds. Pass `--no-install` to stop instead — `No node_modules in — you passed --no-install; run "corepack pnpm install" and re-run guuey deploy.` (auto-install as of `@guuey/cli` 0.20.0 — there is no 0.19.1 on npm; the 0.19.0 `--install` flag is gone and a stale `--install` is silently ignored; the scaffold installs by default too, `--no-install` to skip).
### Scaling and runtime updates
[Section titled “Scaling and runtime updates”](#scaling-and-runtime-updates)
Two live knobs ride `guuey deploy` and also stand alone on `guuey agent config`, which reads and patches the hosted app **without a redeploy** (changes converge within \~5 minutes):
* `--max-pods ` sets the app’s pod count. Your plan sets the ceiling — the server refuses more, naming the limit. With no scaling mode chosen, a hand-set count runs as a **fixed** count — the auto-scaler is off; `guuey agent config --scaling auto` keeps `n` as the ceiling the platform may grow to. See [Plans & billing](/plans-and-billing/).
* `--runtime-auto-update on|off` — `on` (the default) keeps the agent on the platform’s current runtime image; `off` pins it at the image captured by each deploy.
A third knob stands on `guuey agent config` alone: `--worker-reservation-mib ` sets the per-turn worker memory reservation in MiB — the memory each concurrent conversation is given (platform default 256 MiB; your plan’s ceiling is on the readout and the server gates at it; the clamped value is decided at deploy time, and a live app takes it without a redeploy). `--clear-worker-reservation` returns to the platform default. The readout prints it as `Worker memory: … — ceiling … MiB on this plan`.
`--scaling auto|fixed` also stands on `guuey agent config` alone, and it is what the pod count *means*. Under `auto` the platform adds a pod when every turn slot is busy, up to the pod count — the default for an app that never hand-set a count. Under `fixed` the app runs exactly that many pods and the auto-scaler is off. Setting `--max-pods` without ever choosing a mode lands `fixed`; `guuey agent config` with no flags prints the mode the platform runs.
`guuey agent config` with no flags prints the app’s current hosting config; add `--json` for machine-readable output.
After deploying:
```bash
guuey test "hello" # send a test message, print the response
guuey logs --follow # live-tail runtime logs
guuey deployments list # list deployment builds
guuey undeploy # tear down the deployment (keeps the app)
```
`guuey test` talks to your agent as an anonymous visitor, and each run starts a new conversation. To continue one, pass the thread id the first turn printed: `guuey test "and tomorrow?" --thread `. The CLI remembers which visitor started each thread on this machine (under `~/.guuey/test-threads/`), so `--thread` continues only conversations `guuey test` started here. Add `--app-id ` to test another app without changing your `guuey.json` binding. (`--thread` and `--app-id` are as of `@guuey/cli` 0.27.0.)
## Local dev
[Section titled “Local dev”](#local-dev)
Run your built worker locally behind the same SSE endpoint the hosted pod serves:
```bash
guuey dev --serve --port 6790
```
This gives you pod-parity iteration: `GET /readyz` answers 200 while it is serving; 503 means it is draining or degraded (retry); a refused connection means it isn’t listening yet, and `POST /agent/invoke` streams against localhost before you deploy — the same two routes the hosted pod serves. (Plain `guuey dev` — a device bridge with a QR code — is coming soon; the CLI says so itself.)
## Deploying from CI: service tokens
[Section titled “Deploying from CI: service tokens”](#deploying-from-ci-service-tokens)
Your own login (`guuey login`) mints a **user** key — it belongs to you, and it stops working if you log out or rotate. A CI pipeline needs a credential that belongs to the **app**, not to a person:
```bash
guuey tokens create --label "github-actions" --app-id
```
This mints an app-scoped **service token** (`guuey_svc_*`). The secret is printed **once** — store it as a CI secret and pass it through `GUUEY_API_KEY`. Service tokens don’t expire; `guuey tokens revoke ` is the kill switch (auth stops within seconds), and `guuey tokens list` shows prefix, label, and lifecycle — never the secret.
A service token is deliberately narrow: it can call **`guuey agent apply`** / **`guuey agent status`** / **`guuey agent rollback`** (the reconcile routes) and read deployment history for its one app — nothing else. `guuey deploy` needs a user key. The CI verb is `guuey agent apply` — one idempotent call that converges the hosted agent to your checked-in `guuey.json`, never rolls the pod for unchanged bytes, and answers with content hashes your pipeline can gate on. See [Agents as code](/agents-as-code/) for the manifest, the drift gate, and a GitHub Actions example.
Service tokens are for **personal** apps: an app owned by a workspace can’t mint one. A workspace-owned app authenticates CI with a **workspace API key** (`guuey_wkz_*`) instead, passed through the same `GUUEY_API_KEY` variable — see [Workspace-owned apps](/agents-as-code/#workspace-owned-apps-the-ci-credential-is-a-workspace-api-key).
## Beyond the basics
[Section titled “Beyond the basics”](#beyond-the-basics)
The rest of the surface, briefly:
* **Apps** — `guuey apps create|list|get|update|delete|recover|access|publish|unpublish`. `apps delete` starts a **30-day pending deletion**; `apps recover` cancels it inside the window (the widget signing key comes back with it, but billing does not resume on its own). `apps create --for personal|customers` records who the app is for, and `apps get` shows it as `Built for:`. `apps list` and `apps get` show an app’s trial status and end date — see [the 7-day trial](/hosting/#plans-and-the-7-day-trial). `apps access` sets the guest-chat policy (`--guests on|off`, `--guest-limit`), and `apps publish|unpublish` manage the store listing. `apps create` also reports the app’s **first impression** — the greeting and suggestion chips ggui prepares for a fresh app before anyone types, a paid-plan feature: on an entitled account it is requested at creation; on a free wallet the command prints one line saying so with a link to choose a plan (`firstImpression` in `--json`, with `entitled` and, when refused, the `message` and `billingUrl`). `apps list` shows each app’s **Status** and hides archived apps unless you pass `--all` (`--json` follows the same filter, so a script that relied on archived rows adds `--all`). `apps update --chat-theme-file ` sets the app’s chat theme from a theme JSON document (`--clear-chat-theme` removes it) — the write goes through the same validator as the console’s Design → Chat theme editor, and a refusal names the offending key or token. `apps get ` prints the theme’s name and light accent (the effective theme); `apps get --theme-json` prints the **stored** chat theme document as JSON — what a write actually put on the app, `courts` included — and round-trips into `apps update --chat-theme-file`. See [Theme the hosted surfaces](/chat-theming/#theme-the-hosted-surfaces).
* **Custom domains and the slug** — `guuey domains add|list|verify|remove` serves your agent on your own domain (see [Custom domains](/domains/)); `guuey slug claim|release` manages the free short name every plan gets.
* **Hosted MCP servers** — `guuey mcp new ` scaffolds a server, `guuey mcp deploy|list|status|logs|delete` runs it on Guuey, `guuey mcp secrets set|list|unset` manages its secrets, and `guuey mcp state list|export|wipe` inspects or erases a user’s stored state.
* **Third-party MCP authorizations** — `guuey mcp connections [revoke]`, `guuey mcp connect --app ` — see [Connecting third-party accounts](/sdk/#connecting-third-party-accounts).
* **Provider keys (BYOK)** — add your own model key in the console (**Behavior → Model**, or the workspace **Provider keys** panel), or supply it as an app environment variable (`guuey env set ANTHROPIC_API_KEY=…`); a dedicated `guuey byok` command is on the roadmap. See [Credential custody](/security/#credential-custody).
* **Environment variables** — `guuey env set|list|unset`.
* **Billing** — `guuey billing` shows the plan per app, the saved card on file, and the account’s next invoice (total, issue date, per-agent shares) plus recent invoices; `guuey billing invoice [--json]` shows the next invoice alone. `guuey billing topup --app --amount ` adds credits that pre-pay your next invoices: the amount is a whole-dollar figure from the account’s offered list (`guuey billing` prints it; any other amount is refused by name), the command prints the Checkout URL and opens it, and the balance is credited only once Stripe confirms that checkout — never at the command. When the account holds bonus credit, `guuey billing` prints that too (`Bonus credit: $X — applied to usage lines on your next invoices, never the plan fee`), and while a top-up promotion is open, the bonus percentage and its end date. Plan changes stay in the console — see [Plans & billing](/plans-and-billing/). `guuey billing movements [--json]` lists the wallet’s credit movements newest first (the latest 50) — paid (credits purchased, applied to invoices) beside bonus (earned, applied to usage). `guuey billing auto-recharge` shows the wallet’s auto-recharge setting; `--on --amount --threshold --cap ` turns it on — the platform adds that amount of credit whenever the balance would fall under the threshold, never past the monthly cap — after printing the terms and asking you to type `yes` (`--yes` answers for scripts; `--receipts on|off` sets whether each recharge emails a receipt — leave it out to keep the current setting); `--off` turns it off. The console’s Billing page carries the same setting.
* **Usage export** — `guuey usage export` (as of `@guuey/cli` 0.27.0) prints one month of an app’s usage, the figures its meters recorded: managed-LLM cost at the provider’s list price (in total and per model), input and output tokens, generative-UI renders, answers, sessions by the surface that served them, widget opens, pod unit-hours, and storage. `--month YYYY-MM` picks the month (default: the current UTC month), `--format csv|json` the shape (default: CSV, one row per metric), `--out ` writes it to a file. The export is per app per month and says so: every export lists what it cannot attribute (per-end-user splits, workspace-level MCP hosting, calls on your own provider key). The console’s **Plan & usage** page downloads the same CSV.
* **Housekeeping** — `guuey config show|set|unset` for CLI configuration (see [Configuration and environment variables](#configuration-and-environment-variables) for how the layers resolve), `guuey status` for connectivity, `guuey pull` to refresh `guuey.json` from hosted state, and `guuey worker verify` for worker conformance.
Run `guuey --help` for the full surface, and `guuey open dashboard` to jump to the console.
## Configuration and environment variables
[Section titled “Configuration and environment variables”](#configuration-and-environment-variables)
The CLI resolves its configuration through layers — the first layer that supplies a value wins:
1. **Environment variables** — always win.
2. **A repo-local `amplify_outputs.json`** — picked up when your project sits inside a checkout that has one; most projects don’t, and then this layer simply isn’t there.
3. **`~/.guuey/config.json`** — user-level defaults, managed with `guuey config show|set|unset`.
4. **Built-in production defaults** — a fresh install talks to Guuey’s production API and console with zero setup.
Two environment variables cover the endpoints:
* **`GUUEY_API_URL`** — the REST API base every API call targets. Keep the `/v1` suffix — route paths are appended to it.
* **`GUUEY_HOST`** — the platform console URL that `guuey login` opens and `guuey open dashboard` jumps to.
If you override one, override both together: an API key is minted by a console, and a key minted by one console does not authenticate against a different endpoint’s API (that mismatch surfaces as an authentication error — see [Troubleshooting](#troubleshooting)).
**`GUUEY_APP_ID`** overrides the configured app ID (see the deploy precedence above). As of `@guuey/cli` 0.19.0 this is the variable’s name — it was `GGUI_APP_ID` before, and there is no alias: scripts that exported `GGUI_APP_ID` must export `GUUEY_APP_ID`; the old name is ignored. (Breaking under 0.x, hence the 0.19.0 minor.)
### Per-invocation credentials: `GUUEY_API_KEY`
[Section titled “Per-invocation credentials: GUUEY\_API\_KEY”](#per-invocation-credentials-guuey_api_key)
`GUUEY_API_KEY` overrides the stored login for **every** authenticated command — set it and that invocation runs as that key, no `guuey login` involved. It accepts any key the platform mints: your user key, a [service token](#deploying-from-ci-service-tokens) (`guuey_svc_*`), or a workspace API key (`guuey_wkz_*`).
Two properties make it the right tool for switching identities or endpoints:
* **The stored login is a single slot.** `guuey login` writes one credential to `~/.guuey/auth.json`; it is not keyed by host, so logging in against a different endpoint replaces what was there. `GUUEY_API_KEY` sidesteps the slot entirely.
* **`--config ` moves configuration only, never credentials.** The flag relocates `config.json` (non-secret defaults); the stored login stays where it is. Don’t reach for `--config` to isolate credentials — set `GUUEY_API_KEY` instead.
```bash
GUUEY_API_KEY="$MY_CI_KEY" guuey agent status
```
## Troubleshooting
[Section titled “Troubleshooting”](#troubleshooting)
* **A deploy is refused with `TRIAL_EXPIRED`** — the app’s 7-day trial ended without a plan, so the app is paused. Choosing a plan resumes it automatically, no redeploy needed — see [Plans and the 7-day trial](/hosting/#plans-and-the-7-day-trial).
* **Requests fail with an authentication error** — check `guuey whoami` (who you are) and `guuey status` (connectivity); a key minted against one environment does not work against another’s API URL.
# How billing works
> The machine behind the plans table — per-app subscriptions, the trial state machine, the four usage meters, and what the spending cap does and does not bound.
This page explains the billing machine: what a plan attaches to, how the trial decides when an app runs, when each of the four meters ticks, and what the spending cap actually guarantees. For the plan table, current prices, and where to click, see [Plans & billing](/plans-and-billing/) — this page covers the mechanics underneath it.
## Plans attach to apps, payment attaches to a wallet
[Section titled “Plans attach to apps, payment attaches to a wallet”](#plans-attach-to-apps-payment-attaches-to-a-wallet)
A plan is a property of **one app**, not of your account. Your account — or each workspace, which has its own separate wallet — pays one Stripe subscription per paid app, plus that app’s metered usage. There is no account-wide plan ladder: each additional paid app simply carries its own bill, so the number of paid apps you can create is unbounded. One flat operational ceiling (50 apps per wallet, counting archived apps until their 30-day deletion completes) exists as an abuse guard, not a tier — hitting it is a support conversation, never an upgrade prompt.
Exactly **one app per wallet runs without a plan**: the trial slot.
## The trial state machine
[Section titled “The trial state machine”](#the-trial-state-machine)
Every app is in one of three trial states, and every surface — the deploy gate, the console banner, `guuey apps get` — derives the state from the same rule, so they cannot disagree:
* **none** — the app has an honoring paid subscription, or its trial clock hasn’t been stamped yet. The clock is stamped by the platform at its first sight of the app’s live deployment, within minutes of the first successful deploy.
* **active** — the app resolves to the free tier and is inside its 7-day window. It runs with the Free limits.
* **expired** — the window has ended and the app still has no plan. The platform pauses it: the pod scales to zero, and a redeploy is refused with `409 TRIAL_EXPIRED` (a redeploy would only bring up a pod for the platform to scale back down). Conversation history, settings, configuration and domains are all kept.
Putting a plan on a paused app resumes it automatically — the platform detects the honoring subscription on its next pass and brings the pod back, no redeploy needed. The warning emails, what the paused chat surface shows, and where the trial end date is printed are on [Plans & billing](/plans-and-billing/#the-7-day-trial).
The clock is stamped for **every** live app, paid or not — a paid app just resolves to `none` while its subscription is honored. This matters when a plan lapses (below).
**A second plan-less app never serves.** While a plan-less app is live on your wallet — running or paused — creating another app from the CLI (`guuey apps create`, or a first `guuey deploy` that would create one) is refused with a `409` that names the occupying app and links its Plan & usage page. The console instead creates the new app as a **draft** and asks for a plan in place: checkout binds the subscription to that draft, and the draft activates the moment the subscription is honored. A draft holds no trial slot and cannot deploy until then (`409`: the app is waiting for a plan), though it counts toward the 50-app ceiling. Either way, no second app runs without a plan of its own. Once the occupying app takes a plan — or you delete it, which frees the slot right away — your next create succeeds and starts its own 7-day trial. (Restoring a deleted app re-checks the slot: if a new app has taken it meanwhile, the restore asks for a plan first.)
## Which plan an app gets
[Section titled “Which plan an app gets”](#which-plan-an-app-gets)
An app’s effective tier comes from its subscription’s Stripe status. The statuses that keep the paid tier are a closed list: `active`, `trialing`, and `past_due`. Any other status — canceled, unpaid, or anything Stripe invents later — makes the app plan-less: it resolves to the free tier at once for limits and cap purposes (Free limits, a $0 spending cap, no overage). The platform deliberately fails toward *less free service* rather than toward unmetered paid service.
Free limits are the transitional step, not the destination. Because every app’s 7-day trial clock started at its first live deploy, a plan-less app past that clock is `expired` — the next sweep pauses it exactly as an unpaid trial app is paused. Only an app whose lapse lands inside its original 7-day window keeps serving on Free limits: a multi-pod or larger-than-`xs` deployment is brought down to one `xs` pod once the platform has confirmed the lapse across consecutive checks, and the app pauses when the window closes. Resubscribing at any point resumes it, no redeploy.
## The four meters
[Section titled “The four meters”](#the-four-meters)
Paid apps meter four quantities. Each ticks at a different moment:
| Meter | Ticks when | What the plan includes |
| ------------------ | ------------------------------------------------------------------ | --------------------------------- |
| **Generations** | Your agent produces a generative-UI render through `mcp.ggui.ai` | A monthly render allowance |
| **Managed LLM** | The model runs on a Guuey-supplied key | A monthly dollar credit |
| **Pod unit-hours** | A pod *beyond the first* runs for an hour, weighted by size | The first always-on pod |
| **Storage** | The app holds bytes above its included allowance, per GiB per hour | An always-included byte allowance |
Details worth relying on:
* **Generations come in two kinds**: a **new generation**, rendered fresh for an agent on Guuey’s key or on its own, and a **cache hit**, a cached blueprint reuse. Each has its own overage rate, and a cache hit bills less. An agent on its own key still has its generations rendered by ggui, the generative UI service, on ggui’s own keys, so the agent’s key pays for the conversation, not the render. Your included allowance is allocated across kinds **most-expensive-first**, so the free quota covers your new generations before your cache hits.
* **Managed-LLM usage** is priced from provider token rates plus a platform margin and drawn against the plan’s dollar credit; only usage past the credit meters. The Free (trial) tier carries no monthly managed-LLM credit. Instead, a new account gets a small **one-time** managed-LLM trial credit on its wallet, and a Free app’s managed-model turns draw on it; once that lifetime credit is spent, a Free app’s managed-LLM requests are refused with `429 MANAGED_SPEND_CAP` (the message names the credit and what was spent) until the app takes a plan. Bring-your-own-key models are unaffected — see [BYOK](/concepts-byok/).
* **Pod time is billable-only at the source**: the always-on first pod never enters the counter. Extra pods multiply by their size’s billing units (`xs` = 1 up to `xl` = 16 — table in [Plans & billing](/plans-and-billing/#pod-sizes-and-scaling)).
* **The storage allowance is a standing entitlement, not a monthly grant**: your app may hold its included bytes at every moment of the month, so the free GiB-hours grow with the period. Only the time-integral *above* that line bills. Durable app storage is on in production: what your agent writes to a signed-in user’s home counts against the allowance, while a guest’s files and the per-session scratch directory are pod-local by design and never reach the meter (see the note on [Plans & billing](/plans-and-billing/#the-7-day-trial)).
All four counters reset with the **calendar month (UTC)** — billing cycles are anchored to month starts, so “this month’s usage” means the same thing on every surface.
## The spending cap
[Section titled “The spending cap”](#the-spending-cap)
Every paid app has a spending cap — the plan default, adjustable per app (see [where billing is managed](/plans-and-billing/#where-billing-is-managed)). What it guarantees:
* **The cap bounds the usage-driven meters**: generations, managed LLM, and storage. Their metered overage in a month cannot exceed the cap.
* **Pod-hours bill outside the cap.** Pod spend is bounded by something you configure explicitly — the extra pods you allow with `--max-pods`, at their size’s billing units times the flat unit-hour rate (the same rate on every plan — see [Plans & billing](/plans-and-billing/#pod-sizes-and-scaling)) — not by usage that can run away on its own. The cap exists for the unbounded class; your worst-case month is *base fee + cap + the pod spend you configured*.
* **Hard mode** (the default): once cap-relevant spend reaches the cap, requests that would spend more are refused — a generation request with `429 QUOTA_EXCEEDED` (its message says when other metered overage shrank the month’s render allowance), a managed-LLM request with `429 MANAGED_SPEND_CAP` — and a multi-pod app is clamped back to one pod so no further pod capacity rides on a capped app. Serving resumes when the month rolls over or you raise the cap.
* **Soft mode**: serving continues past the cap and the overage bills.
* **A $0 cap means no overage at all** on the usage-driven meters — the Free tier’s standing state, and available on any plan. Setting the cap to unlimited is an explicit act; no default is uncapped.
* You get an in-app notice and an email at **80% and 100%** of the cap, quoting the same cap-relevant figure the enforcement compares. Pure pod spend never triggers these notices, because it never counts toward the cap.
Storage gets one extra, physical guarantee: an app with no cap headroom **cannot write past its allowance** — the write fails with a disk-full error instead of accruing a bill. On a paid app with headroom, writes past the allowance succeed and meter.
## One formula everywhere
[Section titled “One formula everywhere”](#one-formula-everywhere)
Every surface that states a money number — the usage meters on the app’s Plan & usage page, the 80%/100% notices, the enforcement gates, and the Stripe invoice — computes it from the **same shared formula** over the same counters. The figure the panel shows *is* the figure the invoice bills and the figure the cap gates on; a metered invoice is never your first notice of a number no page showed you. Rounding is customer-favorable throughout: cents meters are floored, and a corrupted or backwards-running counter reads as zero rather than ever minting negative or inflated usage.
## Plan changes mid-cycle
[Section titled “Plan changes mid-cycle”](#plan-changes-mid-cycle)
* **Upgrades apply immediately**, with a prorated credit/charge on the next invoice.
* **Downgrades are scheduled for the end of the billing period** — you keep the tier you already paid for until the cycle ends. The queued change is visible on the app’s Plan & usage page, and re-selecting your current plan before it lands cancels it.
* A mid-cycle tier change draws a **metering boundary**: usage before the change settles under the old tier’s rates and allowances, and the period after it starts with the new tier’s *full* allowance — deliberately customer-favorable rather than prorated.
* Cancelling a plan is graceful: the subscription runs to period end, accrued usage is flushed onto the final invoice, and the app then becomes plan-less — Free limits, then paused once it is past its original 7-day trial clock, as described in [Which plan an app gets](#which-plan-an-app-gets). Deleting an app ends its subscription immediately with a final invoice for the usage tail.
## When a payment fails
[Section titled “When a payment fails”](#when-a-payment-fails)
A failed payment does not take your app down. It enters a grace state while Stripe retries on its own schedule:
* The app keeps serving, clamped to one pod.
* The spending cap is enforced in hard mode at **no more than the plan default** — a raised or unlimited cap is suspended for the duration, but a cap you *lowered* below the default stays in force: you asked for less exposure, not more.
* You get an email and a banner naming both restrictions and the fix path (update the payment method — a wallet-level action). Paying the invoice clears the grace state and restores your configured cap and pod count.
## Failure behavior
[Section titled “Failure behavior”](#failure-behavior)
The billing system’s two halves fail in opposite directions, on purpose:
* **Serving fails open.** If a billing record cannot be read at request time, your agent answers anyway — a billing-plane outage never takes a running agent down. Enforcement resumes when reads do.
* **Creation fails closed.** Every app you create costs real infrastructure the moment it exists, so the app-creation and deploy gates refuse when their billing reads fail, rather than minting resources nobody is paying for.
# BYOK & credential custody
> How Guuey seals every credential class with KMS encryption contexts, and why no key — not even your own — ever sits in your agent's process.
Guuey holds several kinds of secrets on your behalf: your model provider keys, the platform’s own managed model keys, the secrets your hosted MCP servers need upstream, and your users’ OAuth tokens for third-party services. Each class has a different owner and a different blast radius, so each gets its own custody path. This page explains the machine behind those paths — [Security & isolation](/security/) has the one-screen inventory; here is the *why* underneath it.
The design rule is the same everywhere: **a secret’s plaintext exists in exactly one process, at the moment of use. Everything else — including your agent’s own code — gets a reference or a short-lived stand-in.**
## One envelope model, many locks
[Section titled “One envelope model, many locks”](#one-envelope-model-many-locks)
Every stored credential is AWS KMS ciphertext — plaintext never touches a database row. But encryption alone isn’t the interesting part. Each ciphertext is sealed with an **encryption context**: a set of claims naming what the secret is and who owns it — a BYOK key is bound to its app, a user’s OAuth token to that user and that service, a hosted-MCP secret to its server.
KMS enforces the context at decrypt time: presenting a ciphertext with any other context is refused by AWS itself, not by Guuey application code. That turns the context into a structural guarantee — a ciphertext lifted from one owner’s row cannot be unsealed as another app, another user, or another credential class, even by code that holds decrypt permission on the key. The partitions are the isolation; the encryption is just the envelope.
Decryption happens only in the one backend process that needs the plaintext, with the owner-bound context, at the moment of use. No read API returns a secret: key listings return a prefix and validation status, secret listings return names only, and OAuth tokens are never returned at all.
## BYOK model keys: your key, your pods, still not your agent’s env
[Section titled “BYOK model keys: your key, your pods, still not your agent’s env”](#byok-model-keys-your-key-your-pods-still-not-your-agents-env)
When you bring your own provider key (Anthropic, OpenAI, or Google — added in the Console under your app’s **Behavior → Model** or at workspace level under **Settings → Workspace → *your workspace* → Provider keys**; OpenRouter keys ride the app environment via `guuey env set OPENROUTER_API_KEY=…`, and a dedicated `guuey byok` command is on the roadmap), it is sealed to your app or workspace before storage. At deploy time, Guuey unseals it with your app’s own context and delivers it only into your app’s pod — and nowhere else. A different app’s deploy presenting your ciphertext would be refused by KMS.
Inside the pod, custody continues. Your agent’s code runs in a jail whose environment is built by allowlist ([Runtime isolation](/security/#runtime-isolation)); the provider-key slots are on a refuse list *by name*, so the pod supervisor keeps your key on its side of the wall and your agent’s model calls leave through the same loopback proxy managed agents use. Your own key never sits in your agent’s process environment.
Why bother, when it’s your key in your own app? Because the jail’s threat model is prompt injection: your agent runs code and reads untrusted input, and anything in its environment is one `env` away from exfiltration. Keeping your key out of the jail means a hijacked conversation can spend against your provider account only through the metered proxy, for the duration of its own invoke — it can never *read* the key and walk away with it.
BYOK invokes draw none of your plan’s managed-model budget — the calls are billed by your provider, on your key. See [Plans & billing](/plans-and-billing/).
## Managed model keys: never in your pod’s reach
[Section titled “Managed model keys: never in your pod’s reach”](#managed-model-keys-never-in-your-pods-reach)
Agents without a BYOK key run on Guuey’s managed provider keys — credentials shared across tenants, which makes them the platform’s most guarded class. Your agent’s process never holds one, and the guarantee is structural rather than policy:
* The jail’s environment is **allowlist-by-construction**: it starts empty and receives only a short list of named platform paths and settings (your app’s own declared variables reach your colocated MCP servers, not the model-calling worker). There is no “everything minus the secrets” subtraction that a new secret could slip past — the managed key isn’t removed from the environment; it was never a candidate.
* In place of a key, your agent’s SDK receives a **loopback base URL and an opaque per-invoke token** — random bytes, bound to one provider and one invoke, revoked when the turn ends, and expiring on its own a couple of minutes after the longest turn a pod will run, even if revocation never runs.
* The proxy on the other side strips whatever authorization headers arrive, validates the token, and injects the real key into the upstream call. The key exists only in the supervisor process, outside the jail.
The worst a fully compromised agent can do with this arrangement is make model calls that are metered and billed to its own app until its token expires. It cannot read a credential, because there is no credential to read.
## Hosted MCP server secrets: write-only
[Section titled “Hosted MCP server secrets: write-only”](#hosted-mcp-server-secrets-write-only)
Secrets your hosted MCP server needs for its upstream calls (`guuey mcp secrets set NAME=VALUE --server `) are sealed per server. The plaintext is never baked into a server image and never returned by any API — `guuey mcp secrets list` shows names only. The MCP gateway unseals a secret per invocation and hands it to your server for that call. Agent pods calling the server see nothing: they authenticate to the gateway with a short-lived federation token, and the secret stays on the server’s side of the gateway.
## OAuth tokens: broker custody
[Section titled “OAuth tokens: broker custody”](#oauth-tokens-broker-custody)
When an end user connects a third-party service to an agent, the OAuth dance happens at the provider — Guuey receives tokens, never passwords. Custody then follows the claude.ai model, detailed in [The MCP proxy](/concepts-mcp-proxy/):
* The token is sealed to **that user and that service** — authorize once, then each app’s access is granted per app via an explicit consent card. The grant is a database row, not a copy of the token.
* The agent pod **never holds the token**. It carries a short-lived, KMS-signed federation token addressed to the gateway; the gateway checks the per-app grant and injects the real credential into the upstream call. Plaintext exists only inside the gateway process for the duration of that forward.
* Disconnecting a service deletes the ciphertext, drains every app grant on the connection, and makes a best-effort revocation call to the provider.
The same envelope model also seals the platform’s identity credentials — per-app signing keys ([The identity model](/concepts-identity/)) and an optional BYO OIDC client secret ([Sign-in on the agent’s page](/page-sign-in/)).
## The custody table
[Section titled “The custody table”](#the-custody-table)
| Credential class | Sealed to | Unsealed by | Ever in your agent’s process? |
| -------------------- | -------------------- | ------------------------------------------------ | -------------------------------------------------------------- |
| BYOK model key | your app / workspace | Guuey at deploy time, into your pod’s supervisor | No — jail refuses the key slots; calls ride the loopback proxy |
| Managed model key | the platform | the pod supervisor’s egress proxy | No — agent gets a per-invoke token instead |
| Hosted MCP secret | the server | MCP gateway, per invocation | No — stays on the server’s side of the gateway |
| End-user OAuth token | the user × service | credential broker, per forwarded call | No — pod carries only a federation token |
Two properties fall out of the design rather than being enforced case by case:
* **No secret survives contact with your agent.** Everything your agent’s process can see is either a path, its own declared configuration, or a stand-in that expires with its one invoke and is useless outside its one scope.
* **A breach of one partition stays in that partition.** Ciphertext is owner-bound at the KMS layer, plaintext is process-bound at the runtime layer, and the network walls in [Security & isolation](/security/#network-posture) close the paths between them.
For the pod model itself — one pod per app, sizes, pause and resume — see [How hosting works](/concepts-hosting/); the jail and the wall between it and the supervisor are on [Runtime isolation](/security/#runtime-isolation).
# Deployments & rollback
> How a Guuey deployment works — immutable build snapshots, row-driven serving, zero-downtime rolling redeploys, byte-exact rollback, and the drift gate.
Every deploy of a Guuey agent — from the [console](/console/), `guuey deploy`, or [`guuey agent apply`](/agents-as-code/) — produces a **build**: an immutable record that holds everything the agent runs from. This page explains what that record contains, why the hash an apply hands back really is the hash of what serves, and what the platform guarantees (and refuses to fake) when you roll forward or back. For the commands and CI wiring, see [Agents as code](/agents-as-code/); for what the pod itself looks like, see [Concepts: hosting](/concepts-hosting/).
## The deployment snapshot
[Section titled “The deployment snapshot”](#the-deployment-snapshot)
A build’s payload is its **snapshot**: your `guuey.json`, re-serialized into one canonical string with the system prompt inlined byte-exact (a `{ "file": "prompts/system.md" }` reference is resolved on your side and the file’s bytes are embedded, so the snapshot is self-contained — no lookup back into your repo). If your app uses Guuey’s generative-UI binding, that entry is injected before the snapshot freezes, so the pod receives it the same way it receives everything else.
**Canonical form** is what makes hashes meaningful. The platform doesn’t store your bytes as-sent; it parses the document against the schema and re-serializes it, which emits keys in a fixed order. The same logical document therefore always produces the same string — and the same sha256 — regardless of how your editor formatted it. Two consequences you can rely on:
* **Idempotency on the reconcile path.** An apply (`guuey agent apply`) or rollback whose assembled snapshot byte-matches the newest active build queues nothing and does not roll the pod — a no-op CI run is genuinely a no-op. `guuey deploy` and the console are simple triggers: they always queue a new build, even for identical bytes.
* **A stable drift gate.** `deployedContentHash.snapshot` in the reconcile response hashes the exact persisted string; the `agentDef` and `systemPrompt` hashes cover your submitted bytes verbatim, so your pipeline can compare them against `sha256sum` of the reviewed files and fail red on any mismatch.
## Builds and their lifecycle
[Section titled “Builds and their lifecycle”](#builds-and-their-lifecycle)
Builds are numbered **per app**, monotonically: each new deploy takes the highest existing build number plus one. A build moves through `queued` → (`building` for code-mode) → `deploying` → `live`; when a newer build goes live, its predecessor is stamped `superseded`. `failed` and `superseded` are terminal — no workload behind them, and they never block the next deploy. The **newest active** build (queued, building, deploying, or live) is the reference point: it is what “unchanged” and rollback compare against, and during a rollout the old live row and the new in-flight row briefly coexist until the handover completes.
`GET /v1/apps/:id/deployments` keeps the full history — every build’s status, plus the snapshot hash and provenance for builds that came through `agent apply` or a rollback (console and `guuey deploy` builds carry `null` for both) — and each build has its own `…/deployments/:n/status` endpoint your pipeline can poll to liveness.
## Row-driven serving: the hash IS what serves
[Section titled “Row-driven serving: the hash IS what serves”](#row-driven-serving-the-hash-is-what-serves)
The deployment record is not a log of what happened — it is the **input** to what runs. The pod boots by reading its snapshot from its build’s record; there is no side channel through which configuration reaches a serving agent. The runtime image is pinned on the record by immutable image **digest**, never a mutable tag. For no-code apps the platform advances that digest when it ships a runtime update — auto-update is the default, and `--runtime-auto-update off` pins the app to the image captured at its last deploy — always by rewriting the record first, so the record still names what runs ([runtime updates](/concepts-hosting/#runtime-updates)). Code-mode images are per-build and change only with a new build.
That is why the snapshot hash in a reconcile or rollback response is a guarantee rather than a receipt: nothing can change what serves without writing a new build record with a new hash. If the hash you got back matches the bytes you reviewed, the agent that answers your users is running those bytes — today and until the next build goes live.
## Rolling redeploys
[Section titled “Rolling redeploys”](#rolling-redeploys)
Agent pods are long-lived, so a redeploy is a **rolling update**, not a restart: the new build’s pod starts alongside the old one, and the old pod is only taken out of service after the new one is ready. In-flight conversations on the old pod are drained, not cut off.
“Ready” is a real check, not a liveness ping. Each pod exposes a readiness check that reports not-ready when the pod cannot do its job — a required colocated tool degraded, or the pod draining for shutdown. A pod that receives its shutdown signal flips to not-ready immediately and stays that way; within one probe window traffic stops routing to it, and any request that still lands on it during the drain gets a structured `503` with a `Retry-After` and succeeds on a live pod on retry (see [Concepts: hosting](/concepts-hosting/#what-failure-looks-like)).
Combined with snapshot idempotency, this gives the operational property CI depends on: **an apply only ever rolls the pod when the bytes changed**, and when it does, users never see a gap.
## Rollback: byte-exact or refused
[Section titled “Rollback: byte-exact or refused”](#rollback-byte-exact-or-refused)
`guuey agent rollback --to ` re-serves a previous build’s exact bytes as a **new** build — a new number, a fresh rollout, but the pinned build’s persisted snapshot as its payload. The platform does not “rebuild from that commit”; it re-serves the string that build’s pod booted from.
The guarantee is strict: before queuing, the stored snapshot is run back through the same canonical assembly a fresh deploy uses, and the result must be **byte-identical** to what was stored. If it no longer round-trips — the canonical form has moved since that build (a schema default was added, a generative-UI binding the build pre-dated) — or the target is a code-mode build or a record without a snapshot, the rollback answers `409 ROLLBACK_NOT_EXACT` and names the remedy (`guuey agent apply` from that commit). Guuey refuses to serve an approximation and call it a rollback.
One boundary to know today: a build whose snapshot pre-dates the app’s generative-UI binding — deployed in the window before the binding was provisioned, typically a deploy fired right after app creation — is refused with `ROLLBACK_NOT_EXACT`; re-apply from the repo instead. Console and `guuey deploy` builds otherwise round-trip, because every deploy path persists the same canonical, binding-injected form.
Three more properties worth knowing:
* **Every gate re-runs.** A rollback passes the same checks as a fresh deploy — schema, lints, tier and size ceilings, build caps. Old bytes don’t grandfather past current limits.
* **Build material only.** Rollback never touches the app’s access policy (auth mode, allowed origins, guest access). If the old manifest declared different `app.access`, apply from that commit instead.
* **Provenance travels with the bytes.** The new record carries the pinned build’s `repo`/`path`/`sha` verbatim — the bytes *are* that commit’s — plus a separate `rolledBackFrom` marker naming the build it re-served.
## Provenance and the drift gate
[Section titled “Provenance and the drift gate”](#provenance-and-the-drift-gate)
Provenance (`repo`, `path`, `sha`) is an **audit label, not a verified fact**: it is asserted by the caller at apply time and read back on the live build and every historical one. Guuey does not check out your repo to confirm it. The *hashes* are the truth — the drift gate exists so your pipeline proves, cryptographically, that the persisted bytes are the reviewed bytes, and provenance then tells a human which commit to look at. Builds deployed outside CI (the console, `guuey deploy`) simply carry no provenance.
## Schema versions: refuse, never guess
[Section titled “Schema versions: refuse, never guess”](#schema-versions-refuse-never-guess)
`guuey.json` carries a root `schema` version (`"1"` today), bumped only when the document shape changes in a way an older reader cannot interpret. The CLI and the platform compare against the **same** constant, published in `@guuey/config`, and both sides refuse rather than best-effort parse a document from the future:
* The CLI stops **before any network call** with `SCHEMA_TOO_NEW` and tells you to upgrade `@guuey/cli`.
* The reconcile and deploy routes answer `400 SCHEMA_UNSUPPORTED` when the document is newer than the platform serves.
An **older** schema is accepted only through a real migration to the current one; none exists yet (there has only been one version), so today the rule is simply “equal, or refused”. The point of the stance is that a document is never silently reinterpreted: what a build means is fixed by its schema version, which is exactly what makes byte-exact rollback a meaningful promise.
# How hosting works
> The runtime model behind every Guuey app — one long-running pod per app, fixed sizes, per-user pinning, pause and resume, and the streaming/history split.
This page explains the machine underneath your deployed app: what actually runs, what it guarantees, and what happens at the edges — pod replacement, saturation, an expired trial, a runtime update. For the how-to — deploy commands, `guuey.json`, MCP flavors — see [Hosting & runtime](/hosting/).
## One runtime per app, not per conversation
[Section titled “One runtime per app, not per conversation”](#one-runtime-per-app-not-per-conversation)
Deploying an app creates its **runtime** — one dedicated, long-running pod, in its own Kubernetes namespace. The unit is the **app** — never the conversation, the user, or the request. All of an app’s conversations share its pod (or pods, once it [scales](#pods-and-per-user-pinning)), and the process serving the first message of the day is the same one that served the last message of yesterday. There is no cold start to absorb, and nothing to warm up: an idle agent is still a running agent.
This is deliberate asymmetry: MCP servers you host on Guuey scale to zero when idle and wake on the next call, because a stateless tool server can afford a wake-up. Your agent can’t — so it never sleeps while its plan or trial is active.
## Isolation and sandboxing
[Section titled “Isolation and sandboxing”](#isolation-and-sandboxing)
Each app’s pod lives in its own namespace behind a default-deny network: nothing else in the cluster can connect to it, and it can reach the public internet but not other tenants, in-cluster services, or the cloud provider’s credential endpoints.
Every hosted workload — a no-code agent’s pod, a code-mode agent’s image, an MCP server you host — runs under [gVisor](https://gvisor.dev). What that means concretely: the workload’s system calls are handled by a user-space kernel instead of the host’s, so the classic container-escape surface (a shared host kernel reachable by any syscall) is structurally reduced. That is the property that lets Guuey host arbitrary compute — code execution, binaries, headless browsers — as ordinary workloads. A no-code agent’s pod runs Guuey’s own runtime image under that same gVisor boundary; on top of it, the worker that executes agent turns is jailed — a bubblewrap sandbox per session with a cleared, allowlisted environment that never contains a managed model key.
The full isolation model — network posture, credential custody, what Guuey structurally cannot reach — is on [Security & isolation](/security/).
## Sizes are reservations, not hints
[Section titled “Sizes are reservations, not hints”](#sizes-are-reservations-not-hints)
A pod size (`xs`–`xl`) is a **fixed CPU and memory allocation**, reserved and capped at the same value. In Kubernetes terms the pod runs in the Guaranteed quality-of-service class: a noisy neighbor cannot starve your pod, and your pod cannot burst into anyone else’s. When you pick `md`, you have 1 vCPU and 1 GiB — not “up to”, not “shared”.
**Per-conversation memory limit.** Each conversation’s worker runs under the app’s worker memory reservation (platform default 256 MiB; `guuey agent config --worker-reservation-mib`, or Settings → Scaling). A conversation that exceeds it is ended alone — the visitor is told the conversation hit its memory limit; every other conversation and the pod keep going. The count is on Plan & usage and the app overview (“N conversations hit their memory limit”), beside the knob that raises it: more memory per conversation means fewer run at once per pod, and a bigger size raises the ceiling.
Each size also carries a bounded pod-local scratch allowance for working files — separate from durable per-user storage, which is its own primitive ([State & memory](/state-and-memory/)). The size table, billing units, and which sizes each plan may use are on [Plans & billing](/plans-and-billing/#pod-sizes-and-scaling).
## Pods and per-user pinning
[Section titled “Pods and per-user pinning”](#pods-and-per-user-pinning)
`maxPods` is a live knob: raise it and the platform converges to the new pod count within about five minutes, with no redeploy and no restart of existing pods. The ceiling comes from the app’s plan; asking past it is refused with the ceiling named, never silently clamped. What the count *means* is the app’s scaling mode: under `auto` it is a ceiling the platform may scale up to when every turn slot is busy; under `fixed` it is exactly the number of pods that run, with the auto-scaler off. A hand-set count with no mode chosen runs as `fixed` — `guuey agent config --scaling auto` keeps it as a ceiling.
Scaling out does not change what any single user experiences, because **each user’s traffic is pinned to one pod**: requests are routed by a consistent hash of the credential each request carries — the same one that identifies the user — so every request and every conversation from one user lands on the same pod for as long as that credential is in use. Pinning is per **user**, not per conversation: two chats from the same person share a pod on purpose, because pod-local working files are kept per user.
When a pinned pod goes away — a scale-down, a runtime update, a node replacement — that user’s next request re-pins to a live pod. A refreshed sign-in token re-pins the user the same way. Conversation history is unaffected (it lives off-pod, see [below](#the-live-stream-and-the-durable-record)); pod-local scratch starts fresh on the new pod, and agents are expected to treat it as working space, not a store.
## What failure looks like
[Section titled “What failure looks like”](#what-failure-looks-like)
The failure modes have designed answers, not accidents:
* **Pod replacement is drain-aware.** A pod being retired finishes its in-flight turns first — including multi-step tool loops mid-flight — before it exits. New requests arriving during the drain get a structured `503` and land on a live pod on retry. Deploys and runtime-image updates roll with a surge pod, so a replacement is ready before the old pod is asked to go.
* **Saturation is refused, not absorbed.** Each pod caps how many turns it runs concurrently for its size. Past the cap, excess requests get a structured `503 POD_SATURATED` with a `Retry-After` hint; Guuey’s chat surfaces show a high-demand notice and retry automatically. A burst therefore degrades into structured refusals the client can retry, instead of the pod taking on more turns than its size can hold.
* **A failed deploy never takes down what’s serving.** Rollouts keep the previous pod running until the new build is ready, so a build that can’t come up leaves the old version serving. An app with no healthy build at all is parked at zero rather than left crash-looping; the next deploy is what revives it.
## Pause, resume, and lapse
[Section titled “Pause, resume, and lapse”](#pause-resume-and-lapse)
An app’s runtime posture follows its billing state, and every transition has a documented way back:
* **Trial end.** Your first app — one app per account (or workspace) at a time — runs on a 7-day trial from its first successful deploy, and the clock is stamped once and never restarts. If the trial ends with no plan, the app **pauses**: its pods go to zero, the public chat surface says the agent is paused, and a redeploy is refused. Nothing is deleted — history, settings, and domains are kept. Choosing a plan resumes the app automatically within a few minutes, no redeploy needed. Details, the one-trial-at-a-time rule, and warning emails: [Plans & billing](/plans-and-billing/#the-7-day-trial).
* **Payment failure.** The app keeps serving, clamped to one pod, while the payment is retried — see [Plans & billing](/plans-and-billing/).
* **Plan lapse.** When a plan stops being honored — canceled or unpaid, as opposed to a payment merely being retried — the app becomes plan-less, and the same 7-day clock decides what happens next. Past that window (which is every app older than a week) it pauses exactly like a trial end — pod to zero, history/settings/domains kept, redeploy refused — and resumes automatically once a plan is on it again. Inside the window it keeps serving on the free tier’s posture (one `xs` pod) until the window ends. Nothing is resized on a transient billing hiccup: a retrying payment still honors the plan (see Payment failure above).
## Runtime updates
[Section titled “Runtime updates”](#runtime-updates)
Every agent pod runs on Guuey’s runtime image, and the platform ships improvements to it continuously. The default is **auto-update**: when a new runtime image is released, the platform rolls each app to it one at a time, with the same drain-aware, surge-first mechanics as a deploy — in-flight turns complete, and a ready replacement exists before the old pod exits. You never redeploy to receive a runtime-image update.
If you’d rather control the moment, `--runtime-auto-update off` (on `guuey deploy` or `guuey agent config`) **pins** the app to the exact image captured at its last deploy; each subsequent deploy captures the then-current image. Pinning is a scheduling preference, not a security exemption: an image with a serious vulnerability is rolled everywhere, pinned or not.
Code-mode apps are the one exception to hands-off updates: your image bakes the runtime image’s contents in at build time, so those improvements reach a code-mode app on its **next deploy** — the platform never rebuilds your image behind your back.
## The live stream and the durable record
[Section titled “The live stream and the durable record”](#the-live-stream-and-the-durable-record)
Two separate systems carry a conversation, and they fail independently:
* **The live stream** is transport. Responses stream over Server-Sent Events directly from the pod running your agent — the client connects straight to the app’s always-on address, with no relay in between. A stream is transient by design: it exists for the duration of a response.
* **The durable record** is storage. The transcript is persisted to the platform’s history store (DynamoDB) as the conversation happens, off-pod and independent of any open stream. Clients read history from the platform, not from the pod.
The split is what makes the lifecycle above safe: history survives pod replacement, scale-to-zero, pause, redeploy, and runtime updates, because the pod was never where it lived. A user can close the tab mid-conversation, come back after the app has been paused and resumed, and pick up the thread.
## Where to go next
[Section titled “Where to go next”](#where-to-go-next)
* [Hosting & runtime](/hosting/) — deploy an app, declare MCP servers, pick sizes.
* [Plans & billing](/plans-and-billing/) — size table, pod pricing, plan ceilings.
* [Security & isolation](/security/) — the isolation model in depth.
* [State & memory](/state-and-memory/) — durable per-user storage, and which environments have it enabled.
* [API & contract stability](/stability/) — what you can build against today.
# The identity model
> One mental model for end-user identity on Guuey — what a token proves, how issuer and audience bind it to one app, guests, and internal federation tokens.
Every Guuey surface — the [widget embed](/embed/), a [custom surface on the SDK](/sdk/), an agent’s [standalone page](/page-sign-in/) — answers “who is this visitor?” the same way: with a **signed token**, verified identically everywhere. There is no per-surface identity, no session Guuey keeps for your users, and no mode where a surface just *asserts* a user. A surface either presents a token that verifies, or the visitor is a guest.
This page is about your **end-users**. Your own builder account and API keys are a separate plane — see [Security](/security/).
## What a token proves, and to whom
[Section titled “What a token proves, and to whom”](#what-a-token-proves-and-to-whom)
An end-user token is a plain signed JWT. Verifying it checks four things, and nothing else grants identity:
* **Signature** — against the issuer’s published keys (`/.well-known/jwks.json`). Guuey never accepts a token it cannot trace to a key that issuer publishes.
* **Issuer (`iss`)** — must exactly match the issuer the app is configured to trust.
* **Audience (`aud`)** — must exactly match the app’s configured audience.
* **Expiry** — verified with zero clock tolerance. Drift is absorbed on the *minting* side (Guuey-minted tokens backdate `nbf` by 60 seconds), never by loosening the verifier — a widened tolerance would weaken verification for every issuer at once.
So a token proves exactly one sentence: *this issuer asserts this subject, for this audience, until this time*. The subject (`sub`) is the stable user id whoever runs the issuer chose; everything durable — threads, memory, files, profile — keys off the identity derived from it.
A token that fails any check is a hard `401`. **There is no guest fallback on a bad token**: a signed-in surface that presents a broken token fails loudly rather than silently continuing as an anonymous visitor whose history the real user could never reach.
## The three ways to sign a user in
[Section titled “The three ways to sign a user in”](#the-three-ways-to-sign-a-user-in)
Which one you use follows from what you already run — but all three end in the same kind of token, verified by the same code path:
| Mode | You bring | Who signs the token | Set up at |
| -------------------------- | ---------------------------------------------------------------------- | -------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| **Guuey’s per-app issuer** | a backend that knows who is logged in | Guuey, with a keypair sealed per app in KMS | [Sign in your own users](/surface-identity/) |
| **BYO OIDC** | an OIDC identity provider (Cognito, Auth0, Okta, …) | your IdP — Guuey never holds your signing key | [SDK](/sdk/#signed-in-users-bring-your-own-auth) · [page sign-in](/page-sign-in/) |
| **Identity endpoint** | a session cookie on a site that shares your [custom domain](/domains/) | Guuey’s per-app issuer — same minting, cookie-silent transport | [Your identity endpoint](/identity-endpoint/) |
Two things make this one model rather than three:
* **Guuey’s per-app issuer is verified as if it were a stranger.** Tokens it mints go through the exact same BYO verification path as tokens from your own IdP — same JWKS fetch, same issuer and audience checks. Guuey grants its own issuer no shortcut.
* **The identity is a pure function of `(issuer, sub)`.** The same `userId` from your backend resolves to the same durable identity in the widget, on your own page, and on the standalone page — one history, one memory, one profile per person, whichever surface they used.
An app’s **authentication mode** (`anonymous` / `native_pool` / `byo`) picks between these: `byo` is everything above; `native_pool` is **Guuey sign-in**, where visitors use a Guuey account verified against Guuey’s own user pool (the same account that signs into the Portal); `anonymous` is guests only.
## Issuer + audience binding
[Section titled “Issuer + audience binding”](#issuer--audience-binding)
Both `iss` and `aud` are resolved server-side from the app’s own configuration and checked exactly, which yields two structural guarantees:
* **A token minted for one app is useless at every other app.** Each app on the per-app issuer has its own issuer string (`https:///`) and its own keypair; a different app is a different issuer with different keys, so the token fails both the signature and the issuer check — this is not a policy lookup that could be misconfigured.
* **A caller can say who the user is, never which app it speaks for.** The mint route accepts a subject, an optional display name and email, and an optional token lifetime (up to an hour) — nothing else. `iss`, `aud`, and the time claims are assembled server-side from the app’s own configuration and cannot be supplied by the caller.
The binding is also why the identity is durable: the end-user id is a hash of the issuer string and the subject, and the issuer string is **frozen** — Guuey never rewrites it, and the CLI refuses to flip an app’s auth mode as a side effect of another command, because changing the issuer would silently re-key every existing user (same human, new identity, orphaned history). Changing auth modes is always an explicit act.
For BYO issuers, one boundary to know: Guuey only ever fetches keys from an `https://` issuer — a non-`https` issuer URL is refused outright rather than dialed.
## The app secret vs. the signing keypair
[Section titled “The app secret vs. the signing keypair”](#the-app-secret-vs-the-signing-keypair)
Enrolling an app in the per-app issuer creates **two different credentials with two different jobs**:
* **The app secret** (`guuey_widget_…`) is your backend’s *right to request tokens*. It is printed exactly once at creation; only its hash reaches storage, and there is deliberately no route to read it back. It authorizes minting an identity for *any* user of your app, which is why it must never reach a browser — [`@guuey/widget-auth`](https://www.npmjs.com/package/@guuey/widget-auth) refuses to run in one. It is rotatable at any time (`guuey widget keys rotate --new-secret`; a plain `rotate` rotates the signing keypair, not the secret).
* **The signing keypair** is what actually signs. It is a per-app RSA keypair whose private half exists only encrypted at rest under Guuey’s KMS, bound to your app so it can never be unsealed to sign for another. It is decrypted only inside Guuey’s signer for the duration of one mint, never cached and never returned by any API — not to your backend, not to the widget. The public half is what everyone verifies against, published at the app’s own JWKS URL. Key rotation publishes old and new keys side by side long enough that no valid token is ever orphaned mid-flight.
Losing the secret costs you a rotation; the private key is not yours to lose. The two never meet: the secret authenticates your backend *to* the signer, the keypair signs *for* your users.
The per-app issuer is also deliberately a **separate identity from Guuey’s internal platform issuer** — separate host, separate keys. No customer-facing token can ever pass an internal platform check, and no internal token can impersonate one of your users.
## Guests, and their deliberate limits
[Section titled “Guests, and their deliberate limits”](#guests-and-their-deliberate-limits)
Where an app allows anonymous visitors, a guest is identified by a random 256-bit secret the client holds — a cookie in browsers, an `x-guuey-guest` header from native clients — hashed into a guest user id. The secret proves nothing about a person; it only makes the *same browser* continuous across requests.
The limits are the design, not gaps in it:
* **Nothing durable.** Guest identities are excluded from durable memory, profile, and the durable file home — those follow only verified sign-in. A guest’s continuity lasts as long as their secret does.
* **Capped.** Guest access is a per-app switch, and guest turns carry a per-guest daily message cap the builder controls (`guuey apps access --guests on|off --guest-limit `) — guests can try your agent, not run up your bill.
* **Never a fallback.** As above: a failed sign-in is an error, not a quiet downgrade to guest.
A surface supplies one identity mode at a time — a token resolver for signed-in users *or* a guest secret, never both.
## Internal federation tokens
[Section titled “Internal federation tokens”](#internal-federation-tokens)
When your agent’s pod calls a platform service — the [MCP gateway and OAuth broker](/concepts-mcp-proxy/), thread history, [hosted state](/state-and-memory/) — it authenticates with a short-lived **federation token** minted by Guuey’s platform issuer and signed in KMS. Each one is scoped to exactly:
* **one user** — `sub` is the end-user the current conversation belongs to, whatever sign-in mode produced them;
* **one resource** — `aud` names the single service or MCP server the token is good against, so the receiving service rejects a token addressed to anything else.
The token expires in minutes — sized to a single agent turn, not a session — so a stolen one was never good for anything beyond the one call it named, and not for long. Additional claims narrow specific doors further (which app is calling, whether a consent was granted for this one thread); none of them carry roles or blanket scopes. Where a claim does open a door — a per-thread consent for the OAuth broker, a read/write grant for the cross-app profile — it is minted from Guuey’s own consent records and names exactly one door; broader entitlement (which servers an app may reach, which accounts are connected) is resolved by the receiving service from its own records, never from the token.
This is the mechanism behind the isolation promises in [Security](/security/): every platform call an agent makes is pinned to one user and one resource by construction, not by convention.
## Where to go next
[Section titled “Where to go next”](#where-to-go-next)
* [Sign in your own users](/surface-identity/) — enrol the per-app issuer, mint from your backend.
* [Your identity endpoint](/identity-endpoint/) — cookie-silent sign-in on a custom domain.
* [Sign-in on the agent’s page](/page-sign-in/) — the standalone page’s modes, including the OIDC redirect.
* [Security](/security/) — the full isolation model this identity model plugs into.
# The MCP proxy
> Why hosted and brokered MCP calls pass through one gateway — identity checks, per-app grants, secret injection, and the OAuth credential broker.
When your agent calls a hosted MCP server, or a third-party server whose users sign in with OAuth, the call does not go straight to the server. It goes through Guuey’s MCP gateway — one process, one door, where every check happens and every secret lives. This page explains what that door does and what it guarantees; for declaring servers in the first place, see [MCP servers: bring your own](/hosting/#mcp-servers-bring-your-own).
The design premise is simple: **for anything that crosses the gateway, the agent pod never holds a long-lived credential.** All it carries for those calls is a short-lived, KMS-signed federation token naming one user and one resource ([Identity & verification](/security/#identity--verification)). Everything sensitive — a hosted server’s API secrets, a user’s OAuth tokens — is sealed at rest and resolved by the gateway at call time. For those services a compromised pod has nothing durable to steal, and revoking access is one central act, not a hunt through deployed images. (The exception is by your choice: an external server declared with static `headers` carries your app’s own `${env.NAME}` values, which do live in the pod and never touch the gateway — see [MCP servers: bring your own](/hosting/#mcp-servers-bring-your-own).)
## The gateway path: hosted servers
[Section titled “The gateway path: hosted servers”](#the-gateway-path-hosted-servers)
A call to a hosted MCP server clears three checks before it is forwarded:
1. **Identity.** The gateway verifies the federation token’s signature, expiry, and audience — the token must be addressed to this route, for this user. A token minted for anything else is refused before any lookup happens.
2. **Grant.** The gateway checks that *this app* has been granted access to *this server*. No grant, no forward — a valid token from the wrong app gets nothing.
3. **Secrets.** If the hosted server needs credentials for its own upstream calls (set with `guuey mcp secrets set`), the gateway decrypts them from KMS ciphertext and injects them per invocation. Secret values are never baked into a server image, never placed in its environment, and never returned by any API ([Credential custody](/security/#credential-custody)).
Then the request is forwarded in-cluster and the response — including SSE streams — is relayed back byte-for-byte. Gateway logging is redacted by design: method names and outcomes, never payloads, tokens, or upstream bodies.
## The credential broker: third-party OAuth servers
[Section titled “The credential broker: third-party OAuth servers”](#the-credential-broker-third-party-oauth-servers)
Declaring an external server with `"credential": "oauth"` (Linear, Notion, GitHub — any server fronted by its own OAuth authorization server) engages the gateway’s second half: a credential broker in the style of claude.ai’s connectors.
The model has two layers, and the distinction is the point:
* **A connection is authorize-once, per user and provider.** Your user signs in to Linear once. That connection belongs to the *user*, not to any app.
* **A grant is explicit, per app.** Each agent that wants to use the connection must be granted it individually — the user answers a consent card in chat (“*app* wants to use your *server* account” — **Always allow**, **Allow this chat**, or **Not now**). Nothing is ever auto-granted; **Not now** records nothing and the agent simply asks again.
**First-party hosts are reserved.** `"credential": "oauth"` against one of Guuey’s own hosts (`platform-mcp.` / `oauth.`) renders Guuey’s own consent screen, so it is restricted to first-party apps. For any other app, `guuey deploy` and `guuey agent apply` (including `--dry-run`) refuse the entry at the save, naming the host and the app id; the deploy-time gate refuses it the same way. Drop the entry or use a non-oauth credential.
### How a call works
[Section titled “How a call works”](#how-a-call-works)
Before each turn, the platform checks whether this user has connected this server to this agent. If not, the turn runs without that server’s tools and the consent card appears — the agent degrades, it doesn’t break. (What that flow looks like on your surface, including the return redirect, is covered in [Connecting third-party accounts](/sdk/#connecting-third-party-accounts).)
Once connected and granted, a tool call flows like this:
1. The pod sends the call to the gateway’s brokered route for this app and server, carrying only its federation token.
2. The gateway verifies the token is addressed to *exactly* that app-and-server route, and that the app named in the token matches the route. One app cannot present another app’s route, even with an otherwise valid token.
3. The gateway checks the grant: allowed always, allowed for this chat only, or blocked. A “this chat” grant is bound to the thread it was given in.
4. The gateway decrypts the user’s token, injects it as the upstream `Authorization` header, **strips the federation bearer**, and forwards only a fixed allowlist of headers. The third-party token exists in plaintext only inside the gateway process, for the duration of the forwarded call.
5. The response streams back through the same relay the hosted path uses.
The authorization dance itself is standards-grade OAuth, with the strict options non-negotiable: PKCE with S256 is required (providers that don’t support it are refused), the anti-forgery state is single-use with a ten-minute lifetime, and every URL the broker fetches during provider discovery — including every redirect hop — passes an SSRF guard that refuses internal and non-HTTPS targets. Where the user lands after authorizing is validated against an allowlist: your app’s allowed domains plus Guuey’s own surfaces, nothing else.
The client identity presented to the provider is **Guuey’s by default**: the broker registers itself via client-ID metadata or dynamic client registration, and while a provider supports either, a builder cannot substitute their own `client_id` in the flow. Some authorization servers support neither and only accept clients registered by hand (Google, GitHub and similar). For those, you register an OAuth client with the provider yourself and save it on the agent’s **Tools** tab ([Pre-registered client](/console-tools/#connected-accounts)). The broker uses your client only for that agent and that server, and only when the provider cannot register one for Guuey. Until a matching client is saved, connecting to such a provider fails with the reason `client_registration_unavailable` (the browser returns with `?error=client_registration_unavailable`, and the Console’s Tools tab turns it into a notice pointing at the pre-registered client form).
### Token lifecycle
[Section titled “Token lifecycle”](#token-lifecycle)
* **Refresh** happens at call time, inside the gateway, and is single-flight per connection — concurrent tool calls share one refresh instead of racing the provider.
* **Expiry** is handled honestly: an upstream 401 triggers one refresh and one retry; if the token still doesn’t work, the connection is marked expired and the next turn’s check re-asks the user. No silent retry loops.
* **Disconnect** (from the Guuey app’s *Settings → Connected services*, or `guuey mcp connections revoke`) deletes the stored credential — the KMS ciphertext is gone — drains every app grant on the connection, and makes a best-effort revocation call to the provider so the token is dead on their side too.
* **Erasure** rides the platform’s deletion cascades: deleting a user deletes their connections and sealed credentials; deleting an app deletes its grants.
## What the boundaries guarantee
[Section titled “What the boundaries guarantee”](#what-the-boundaries-guarantee)
* **The pod never holds a third-party token.** Not in a file, not in an environment variable, not in transit to the upstream. It holds a minutes-lived Guuey federation token addressed to the gateway, nothing more.
* **Hosted-server secrets never leave the gateway’s custody.** Write-only from your side; injected per invocation; absent from images, pod environments, and every API response.
* **Guuey receives tokens, never passwords.** The sign-in happens at the provider; the broker sees the OAuth grant, not the credentials behind it.
* **Users hold the grant, builders see aggregates.** As a builder you see per-server health on your app’s Tools tab — how many users connected, how many granted your agent, the last refresh error — never a user list and never a token.
* **Anonymous guests never store or use third-party credentials.** A guest turn gets no consent card and no brokered tools; the check fails closed.
* **Blocking is deliberate.** The only way a server is recorded as blocked for an app is the user saying so in their connected-services settings — a dismissed card is not a “no”, it’s a “not now”.
Tokens are KMS-sealed at rest, decrypted only inside the gateway process, and every mechanism above holds on the production gateway today. If you’re evaluating the wider isolation story — pods, networks, data at rest — start at [Security & data protection](/security/).
# Use the Console
> The operator console at platform.guuey.com — every agent you host on Guuey, its deployments, tools, design, users, and billing, in one place.
The Console, at [platform.guuey.com](https://platform.guuey.com), is where you **create and operate** the agents you host on Guuey. Whether an agent was created here or deployed from code with the [CLI](/cli/), it shows up here — with its deployments, sessions, logs, tools, design, users, domains, and billing.
Sign in with your Guuey account — the same one you use in Portal. A scope switcher in the top bar moves you between your **personal** space and any **workspace** you’re a member of; everything below (agents, registries, usage, billing, settings) is read in the active scope.
## The sidebar map
[Section titled “The sidebar map”](#the-sidebar-map)
The main sidebar has six sections:
| Section | What’s there |
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Home** | Your agents as cards, with a jump into each |
| **Agents** | The full agent list and the create form — see [Agents & deployments](/console-agents/) |
| **MCP Registries** | Workspace MCP server catalog, your hosted servers, and hosting billing — see [Tools & MCP](/console-tools/) |
| **Usage** | Generations used this month, per agent — see [Billing & workspaces](/console-billing/) |
| **Billing** | The wallet’s control tower: next invoice, invoice history, payment methods, credits, and every agent’s plan at a glance — see [Billing & workspaces](/console-billing/) |
| **Settings** | Your profile, API keys, and workspace management — see [Billing & workspaces](/console-billing/) |
An external link at the bottom of the sidebar opens these docs.
## Inside an agent
[Section titled “Inside an agent”](#inside-an-agent)
Opening an agent swaps the sidebar for that agent’s own navigation, grouped the way you work:
* **Agent** — Overview and Playground → [Agents & deployments](/console-agents/)
* **Configure** — Behavior, Tools, Design, Users → [Agents & deployments](/console-agents/), [Tools & MCP](/console-tools/), [Design & distribution](/console-design/)
* **Distribute** — Distribution, Embed, Domains → [Design & distribution](/console-design/)
* **Monitor** — Sessions, Deployments, Logs → [Agents & deployments](/console-agents/)
* **General** — Billing and Agent Settings → [Billing & workspaces](/console-billing/), [Agents & deployments](/console-agents/)
A dropdown above the navigation switches between your agents without going back to the dashboard.
## Console and the CLI
[Section titled “Console and the CLI”](#console-and-the-cli)
Two doors onto the same platform:
* **The Console** is where no-code agents are created — describe the agent and it deploys — and the shared operations surface for every agent (deployments, sessions, billing, domains, design).
* **The [CLI](/cli/)** is the code path: `guuey deploy` ships an agent defined in `guuey.json` (see [Agents as code](/agents-as-code/)). The Console’s **Redeploy** button re-runs your most recent deploy; new configuration still comes from your project.
## Truthful by policy
[Section titled “Truthful by policy”](#truthful-by-policy)
Where a control isn’t built yet, the Console says so: sections that aren’t live render a labelled “coming soon” panel — often naming the CLI command or file that does the job today — rather than a form that silently does nothing. Editable controls are wired; where one saves a field the runtime doesn’t read yet (the Adapters labels), the panel says so right beside it.
## Platform health
[Section titled “Platform health”](#platform-health)
Platform health lives at [status.guuey.com](https://status.guuey.com) — live checks on the Console, the CLI API, agent endpoints, and the MCP gateway. If the Console misbehaves, look there first.
# Agents & deployments
> Operate an agent from the Console — overview, playground, behavior, users, deployments and redeploys, sessions, logs, memory, and agent settings.
**Agents** in the Console’s sidebar lists every agent in the active scope. **New agent** asks for exactly one thing — a name — creates the agent, and lands you on its overview; the definition itself comes from a deploy (the console’s create flow or [`guuey deploy`](/cli/)).
Workspace agents follow workspace roles: members see these pages read-only; editing needs admin or above.
## Overview
[Section titled “Overview”](#overview)
The overview is the agent’s front door:
* **Connect your agent** — a guide that walks the required steps (connect a deploy, get a first session), then collapses into a ship-it checklist (embed the widget, publish the listing, configure auth) once the agent works.
* **Live endpoint** — once the latest deploy is live, its endpoint URL sits at the top of the overview with a copy button, next to a **Test in playground** shortcut. Build progress lives on the Deployments tab.
* **Usage this month** — one card, per calendar month (UTC), in the card’s own order: **sessions** (one per new conversation started with the agent, on any channel), **answers** (one per completed agent reply, streamed or not — counted when the reply lands), **generations** (against the plan’s quota), **managed LLM tokens** (in · out) and **widget opens** (one per server-side load of the embedded widget page — the page load, not a click; proxy, OG and favicon fetches never count). Sessions, answers and widget opens are display counters — not billed, not metered, no rate-card line; the billed axes are unchanged (see [Plans & billing](/plans-and-billing/)).
* **Banners that matter** — if the app is on its [7-day trial](/plans-and-billing/#the-7-day-trial), a countdown banner shows here (and on the billing panel). At expiry the platform pauses the app — the pod stops serving — and choosing a plan resumes it within a few minutes, with no redeploy. A failed payment shows its own banner pointing at Billing.
## Playground
[Section titled “Playground”](#playground)
Chat with your deployed agent without leaving the Console. The playground talks to the same live endpoint your users reach, so what you see is what they get.
## Behavior
[Section titled “Behavior”](#behavior)
What this page shows depends on how the agent was built.
**Created from a brief (no code)** — one section, **System prompt**, over the prompt your live agent runs. **Save & redeploy** writes your edit into the live build and redeploys it: a new build, live in about a minute. If your live prompt ends with Guuey’s grounding note (the create wizard adds one), the note stays on the end of the new prompt. While a build is still deploying, saving waits until it is live. The box under it, **For signed-in users**, works the same way for a signed-in override — a full replacement of the base prompt for authenticated sessions; leave it empty to drop the override. The model still comes from the brief you created the agent with; to change it, pull the agent with `guuey pull --app-id `, edit `guuey.json`, and run `guuey deploy` (see the [CLI guide](/cli/)) — the same round-trip also edits the prompt, in `prompts/system.md`. You can also declare the agent in a `guuey.json` and run `guuey agent apply` ([Agents as code](/agents-as-code/)). The Console keeps monitoring, keys, and billing for it either way.
**Deployed from code** — sectioned configuration for how the agent thinks:
* **System prompt** — the live prompt comes from your agent’s own code: `agent.systemPrompt` in `guuey.json`, inline or as a file path (see [Agents as code](/agents-as-code/)). The Console edits your **draft**, with a **For signed-in users** twin: the connect guide carries the draft to your agent until its first code deploy. After that the deployed snapshot is the live prompt, and editing the draft never changes a running agent.
* **Model** — the **per-app BYOK keys** are live: add an Anthropic, OpenAI, or Google key (the CLI’s `guuey byok set` also accepts OpenRouter). Choosing the model itself from the Console is marked coming soon — the model comes from your `guuey.json`.
* **Generation, Tool use, Guardrails** — labelled coming soon, with the honest current state spelled out in each panel: every allowed tool runs automatically (the allowlist is the control), and Guuey adds no content-safety pass of its own — your model provider’s safety layer and your system prompt are what apply.
## Users
[Section titled “Users”](#users)
Who may talk to the agent, and who has:
* **Authentication** — three modes: **Anonymous**, **Guuey sign-in**, or **Bring Your Own Auth** (your Auth0/Clerk/Firebase/any-OIDC issuer URL and audience; Guuey verifies your users’ tokens against your provider’s published keys, and shows the redirect URI to register at your IdP). See [Sign-in on the agent’s page](/page-sign-in/) and [Sign in your own users](/surface-identity/) for how each mode reaches your users.
* **Directory** — the real end users who have talked to this agent, aggregated from sessions.
## Deployments
[Section titled “Deployments”](#deployments)
The active build sits on top, the full deploy history below. Each build walks `queued → building → pushing → deploying → live`; older builds become `superseded`, and failures say `failed`.
* **Redeploy** re-runs your most recent deploy server-side — it needs at least one prior deploy, and the button explains itself when disabled (no prior build, or a deploy already in flight).
* **Runtime size** is chosen on the deploy — `guuey deploy --size sm` — not in the Console yet.
* Click a build for its detail page: an Overview tab and the build’s **Env** vars; Logs, Artifacts, and Diff tabs are marked “soon” right in the tab strip.
## Sessions, Logs, Memory
[Section titled “Sessions, Logs, Memory”](#sessions-logs-memory)
* **Sessions** — every conversation, with summary stats (including error rate), filters by status, identified vs anonymous, and date range. Click a session for its event timeline.
* **Logs** — live output from the running agent: pick a time range, search, and auto-follow the tail.
* **Memory** — not in the sidebar: reach it at `/apps//memory`, or from a session’s thread-id link. It lists the agent’s signed-in end users and, under them, **Conversations**: every stored conversation, anonymous widget visitors included, newest first, filtered to one user when you pick one. Each row opens the full transcript. What the agent actually remembers is described in [State & memory](/state-and-memory/).
## Agent Settings
[Section titled “Agent Settings”](#agent-settings)
Sectioned like the rest of the Console; the sidebar shows only sections that apply to the agent’s hosting mode:
* **General** — name, description, default shell, welcome prompt, plus **Branding** and the **Standalone page** policy (covered in [Design & distribution](/console-design/)).
* **Deployment** — the app’s **pod-count limit** (live — the runtime converges the running deployment onto it, up to your plan’s ceiling; see [Plans & billing](/plans-and-billing/)) and its **environment variables**. Runtime size stays a deploy-time flag for now.
* **Tools** — the browser origins allowed to call your agent’s endpoint. It’s the same list the [embed](/embed/) reads, but with two readers: the endpoint’s CORS check treats a bare domain as covering its subdomains, while the widget’s frame check matches it to the apex only — see [Allow your site’s domain](/embed/#1-allow-your-sites-domain).
* **App ID** — the agent’s public identifier, the one you pass to the [SDK](/sdk/).
* **Adapters** — capability labels on the app today; they don’t grant or block device access.
* **LLM, Data ownership, Source, Integrations, Webhooks** — coming-soon panels, each stating what to use instead today (BYOK lives under Behavior → Model; there is no webhook dispatcher yet, so poll Sessions or read thread history through the SDK).
* **Danger zone** — transfer the agent to a workspace, or delete it. Deletion stops serving and is **reversible for 30 days**: the settings page grows a restore card with the deadline, and only after that window is it permanent. Workspace apps: only admins can delete.
# Billing & workspaces
> The Console's money and membership surfaces — usage, per-app plans and spending caps, the wallet view, workspaces, API keys, and the audit log.
Billing on Guuey is **per app** — each deployed agent carries its own plan (the model itself is in [Plans & billing](/plans-and-billing/)). The Console splits the money surfaces accordingly: each app’s own billing panel holds the actions; the top-level pages give you the whole picture.
## Usage
[Section titled “Usage”](#usage)
**Usage** in the sidebar shows one number per agent: **generations used this month**. It’s deliberately one number — it is the same meter the runtime increments and the quota gate enforces, so what you read here is what you’re held to.
## The app’s Plan & usage page
[Section titled “The app’s Plan & usage page”](#the-apps-plan--usage-page)
Open an agent → **Plan & usage** (or `/apps//plan`). This is where the actions live:
* **Choose or switch the plan** — checkout runs through Stripe. Upgrades apply immediately and prorated; downgrades take effect at the end of the billing period. Cancel takes effect at period end, and can be resumed before then.
* **Trial state** — a free app’s [7-day trial](/plans-and-billing/#the-7-day-trial) countdown shows here, on the overview banner, and on the wallet card — all reading the same flag, so they can’t disagree.
* **Usage meters** — per-meter usage for the current period, projected from the same numbers the invoice is built from. The panel is explicit about what’s billable: a meter your plan holds at its included allowance says the plan “pauses at its included quota” rather than quoting a rate nothing will invoice — meaning requests past the allowance are refused until the month rolls over (the pod keeps serving; this is not the trial-expiry pause).
* **Export usage** — download one month of the agent’s usage as CSV: managed-LLM cost at the provider’s list price (per model), tokens, renders, sessions by where they happened, and pod and storage time. It is per agent, never per end user, and each export lists what it cannot attribute. `guuey usage export` gives the same file from the CLI.
* **Spending cap** — a monthly USD budget with two modes. **Hard** (the default) refuses requests that would spend past the cap until the month rolls over or you raise it; **soft** keeps serving and bills the overage — soft requires a live payment method. Leave the budget blank for the plan default. Details in [Metered usage and the spending cap](/plans-and-billing/#metered-usage-and-the-spending-cap).
## Billing (the wallet view)
[Section titled “Billing (the wallet view)”](#billing-the-wallet-view)
**Billing** in the sidebar is the active scope’s wallet — the control tower for money and the account: the **next invoice** as Stripe previews it (one per app subscription — plan fee, usage so far, credits applied, the card it charges), the **invoice history** with Stripe’s hosted page and PDF for each, the **payment methods** on file, **credits** (balance and top-up), the workspace’s MCP hosting subscription, and every app subscription in one list. Adding or replacing a card and tax details happen in **Stripe’s Customer Portal** (**Manage in Stripe**). Deliberately, everything else here is a link — plan changes and caps live on each app’s own Plan & usage page, the pod limit on its Settings page, hosting actions on MCP Registries → Billing — so no field has two writers.
## Account Settings
[Section titled “Account Settings”](#account-settings)
**Settings** in the sidebar is about you:
* **Profile** — display name, email, and account deletion (with a pending-deletion window you can cancel).
* **API keys** — your personal `guuey_user_*` keys, the credential kind `guuey login` mints for CLI use on your own machine. Create and revoke them here.
* **Workspace** — the workspaces you belong to, and where you create a new one.
* **Notifications** — coming soon; the panel says what it will cover.
For CI, don’t use a personal key: apps deployed from a repo take an **app service token** — see [Agents as code](/agents-as-code/#the-ci-credential-an-app-service-token).
## Workspaces
[Section titled “Workspaces”](#workspaces)
A workspace is a shared scope with its own wallet, agents, and MCP registry. From Settings → Workspace, open a workspace to manage it:
* **Members** — invite by email; roles are **Owner**, **Admin**, and **Member**. Members read; admins edit, invite, and delete apps.
* **Incoming transfers** — agents being transferred into the workspace await acceptance here.
* **API Keys** — workspace keys (`guuey_wkz_*`) for CI and automation. A workspace key authenticates *as the workspace*; revoking one stops any pipeline using it immediately.
* **Provider keys** — the workspace’s own AI-provider keys ([BYOK](/concepts-byok/)). Apps in the workspace use these unless an app sets its own under Behavior → Model.
* **OAuth clients** — register client credentials (redirect URIs, and scopes for profile, email, and app read/write) for the day your own application signs users in with Guuey. Registration is live; the sign-in and token endpoints are not yet, and the page says so — credentials you register are kept and start working when they ship.
* **Audit** — the workspace’s event log, filterable by event family and time window, exportable as CSV.
# Design & distribution
> How an agent looks and where it reaches users — the chat theme editor, branding, the standalone page, Portal listing, embed, and custom domains.
The **Design** tab (under Configure) and the **Distribute** group (Distribution, Embed, Domains) shape what users see and where. Branding and the standalone-page policy live under **Agent Settings → General**; they’re covered here because they’re design decisions.
## The Design tab
[Section titled “The Design tab”](#the-design-tab)
* **Chat theme** — the live control. An editor over the [`@guuey/chat` theme schema](/chat-theming/): palette (light and dark), corner radius, density, font family, and text scale (radius and text scale style the widget’s own chrome and reach generated cards as well — the radius as a five-stop ladder, the scale as the base of the cards’ font ramp; density is the widget’s own spacing knob and never reaches a card). The preview is the real chat kit rendering a sample transcript from your draft, so what you see is what the widget paints. Saves are validated with the same validator the API uses, and the theme applies to every surface Guuey hosts for the app — the embedded widget, the standalone page, and Portal chat — **with no redeploy**. “Start from” seeds the editor from a shipped theme or one of the preset documents (Classic blue, Modern, Playful). This is the opened panel’s look; the launcher bubble — color, icon, shape, side — is set by the embed snippet’s options ([Customize the launcher](/embed/#3-customize-the-launcher)). The same theme can live in your repo as code — `app.theme` in `guuey.json`, inline or `{ "file": "theme.json" }`, converged by `guuey agent apply` (declarative apps) or applied by `guuey deploy` when the app has no chat theme yet (a deploy never overwrites a theme set here) — see [Theme the hosted surfaces](/chat-theming/#theme-the-hosted-surfaces), which also carries a copiable prompt for your coding agent.
* **Default appearance** — *Follow the host page*, *Light* or *Dark*; this is the theme’s `mode`. *Follow the host page* states nothing (the key is absent from the saved document): the widget paints the page’s own theme option, else light. A stated *Light* or *Dark* is what paints when the page states nothing — the viewer’s explicit choice (the embed snippet’s `theme` option, `?theme=` on the standalone page) always outranks it. “Start from” keeps this setting.
* **Brand** — the agent’s name and description as surfaces display them.
* **UI Library** — the generated-UI components this app has produced and cached for reuse.
* **Inheritance** — workspace design defaults with per-app overrides; coming soon.
## Branding (Settings → General)
[Section titled “Branding (Settings → General)”](#branding-settings--general)
The agent’s icon, social-share (OG) image, and accent color, each painting one thing: the **icon** shows on the agent’s own page and its Portal app page; the **OG image** is what share links unfurl; the **accent** is the accent-only fallback for the chat surfaces of an app with no chat theme — where a chat theme exists it wins ([precedence](/chat-theming/#theme-the-hosted-surfaces)), and the field then shows the pair that actually paints — the theme’s accent under its `onAccent` for the effective mode. Nothing in Portal’s Discover listing reads the accent. Image uploads are hosted by Guuey; clearing a field really clears it.
## Standalone page (Settings → General)
[Section titled “Standalone page (Settings → General)”](#standalone-page-settings--general)
Every deployed agent can have its own page — on its Guuey slug host and on any [custom domain](/domains/). The policy object here controls what that page does: on/off, welcome line, call-to-action, whether search engines may index it, and the identity endpoint that signs visitors in on a custom domain (see [Sign-in on the agent’s page](/page-sign-in/)). The page’s title is always the app’s name. *How* visitors sign in is a separate control (Users → Authentication); *whether* they must sign in at all — guest access — is set under Distribution → Sharing & access.
## Distribution
[Section titled “Distribution”](#distribution)
The status-and-handoff surface for the agent’s [Portal](/portal/) presence:
* **Listing** — name, description, category, tags, and visibility, editing the agent’s store listing directly. The Console applies no read-only guard here: it will list a write-capable agent publicly if you tell it to, so keep agents with connected accounts unlisted unless you mean it.
* **Publish / Unpublish** — flips the listing between draft and published. Publishing controls **discoverability** in Portal only; the direct link and the agent’s page keep working for any live agent, listed or not. To actually restrict access, turn off guest access below or turn the standalone page off. A suspended listing is a platform moderation state and is read-only here.
* **Sharing & access** — the share link (using your claimed slug when you have one), the **Guest access** switch (whether visitors may talk without signing in), an optional daily message limit per guest, and whether the “sign in to keep your history” prompt shows.
## Embed
[Section titled “Embed”](#embed)
The one-tag widget for your own site: the snippet, the allowed-origins list (for the widget’s frame check a bare domain covers the apex only, and localhost entries need the scheme — `http://localhost:3000`; the agent endpoint’s CORS check is looser, see [Allow your site’s domain](/embed/#1-allow-your-sites-domain)), bubble/icon colors, and — for signed-in embeds — the identity section with the widget key ceremony that mints your token-signing key. The full integration guide is [Embed & share](/embed/); signed-in identity is [Sign in your own users](/surface-identity/).
## Domains
[Section titled “Domains”](#domains)
Attach a hostname you own to the agent’s page. The tab walks verification and TLS provisioning, polling until the domain is serving — records to create, current status, and any error are shown per domain. The DNS specifics live in [Your own domain](/domains/).
# Tools & MCP
> The Console's MCP surfaces — an agent's Tools tab with connected accounts and state, plus MCP Registries for workspace catalogs and hosted servers.
Agents get capabilities from MCP servers. The Console shows this on two surfaces: each agent’s **Tools** tab, and the workspace-level **MCP Registries** section.
## The Tools tab
[Section titled “The Tools tab”](#the-tools-tab)
Open an agent → **Tools** to see where its capabilities come from:
* A **workspace agent** draws on the MCP servers enabled for its workspace — the header links to **Manage registries**. The full per-tool listing here is marked coming soon.
* A **personal agent**’s servers come from its own deployed configuration — the `mcpServers` in its `guuey.json` (see [Agents as code](/agents-as-code/)). The workspace registry is workspace-only by design.
### Connected accounts
[Section titled “Connected accounts”](#connected-accounts)
If the agent’s live deployment declares an MCP server with `credential: "oauth"`, a **Connected accounts** panel appears per server: how many users have connected, how many granted this agent, the server’s issuer, the last authorize, and any recent refresh error.
The model to know: your users sign in to those services **with their own accounts**. Guuey holds the tokens — your agent never sees them — and each connection belongs to the user, who manages it inside the app under **Settings → Connected services**. When you run [`guuey mcp connect`](/cli/) yourself, the browser dance lands back on this tab with a confirmation notice. How the broker works end to end is in [MCP proxy & credentials](/concepts-mcp-proxy/).
**Pre-registered client.** Some authorization servers cannot register clients on their own: no dynamic client registration and no client-ID metadata documents (Google, GitHub and similar). Each server’s panel has a **Pre-registered client** section for them. Register an OAuth client with the provider using the **Redirect URI to register** shown there, then paste its client ID and secret and save; leave the secret empty for a public client. The secret is sealed on save and never shown again, and saving replaces the client ID and secret together. Guuey uses your client only when the provider cannot register one for Guuey; without one, a connection attempt returns with `client_registration_unavailable` and this tab shows a notice saying so. If the agent’s live declaration of that server changes, the section flags the saved client as saved for the old declaration; save it again to re-pin it.
### State
[Section titled “State”](#state)
For servers that keep per-user state, a **State** panel lets you inspect that server’s key-value store — the same primitive described in [State & memory](/state-and-memory/). In environments where durable state hasn’t been enabled yet, the section carries a plain warning instead: state is ephemeral there, and writes do not survive pod restarts.
## MCP Registries
[Section titled “MCP Registries”](#mcp-registries)
The sidebar’s **MCP Registries** section has three tabs. It is **workspace-scoped**: in personal scope it explains that registries are a team-workspace feature and offers to create one.
### Registries
[Section titled “Registries”](#registries)
The workspace’s server catalog — which MCP servers are enabled for the workspace’s agents. Members see the list read-only; workspace admins can **Add Server** and **Pause / Resume / Remove** entries.
Click a server for its detail page: the server’s tools (with schemas), and its State panel. Test-invoke, a consent log, sync settings, and version pinning show as visible coming-soon stubs.
### Hosted servers
[Section titled “Hosted servers”](#hosted-servers)
The workspace’s **own** MCP servers running on Guuey — the ones you shipped with `guuey mcp deploy`. This view reads the same data as `guuey mcp list` / `guuey mcp status`:
* **Delete** is live from the Console, and fail-closed: if agents still hold grants on the server, the delete stops and asks you to confirm against the named list — the same flow the CLI walks.
* **Resize** has no console button on purpose — it’s a full redeploy from your local source, so each card shows you the CLI command instead of a control that couldn’t do the job.
### Billing
[Section titled “Billing”](#billing)
The workspace’s MCP **hosting subscription** — checkout, total hosted units, and the first-month credit line. If a `guuey mcp deploy` is refused because hosting isn’t paid for, the refusal points at exactly this page. Agent plans are separate and per app — see [Billing & workspaces](/console-billing/).
# Your own domain
> Serve a deployed agent from a hostname you own — one CNAME for a subdomain (or three records for a root domain); Guuey verifies ownership and provisions TLS.
A **custom domain** puts the agent — the page, the live chat stream, and the conversation history, all same-origin — behind a hostname you own, like `chat.example.com`, with TLS provisioned for you. You create exactly one DNS record, once; every migration after that happens on Guuey’s side, and your DNS never changes again.
Every deployed agent already answers at its **always-on domain** — the stable `https://.agents.…` address that `guuey deploy` prints as `Live at`. That is the agent’s *infrastructure* address: it is where the [client SDK](/sdk/) invokes the agent, where `/readyz` answers, and the CNAME target for your custom domains. It does not serve a page — open it in a browser and you get a bare `404`. The agent’s **standalone page** lives on the hosts meant for people: its **slug host**, a **custom domain**, and the first-party [share page](/embed/#share-links).
Every deployed agent also has a **slug host** from its first deploy: the platform claims a default slug (`-<4 characters>`) the moment the first build goes live, and `guuey deploy`, `guuey agent apply --wait` and the console show it as `Your agent's page: https://…`. Nothing to set up — and if you’d rather have a name of your own, `guuey slug claim ` replaces it (a slug you claim yourself is never overwritten).
Custom domains are available on the **Pro** and **Scale** plans — see [Plans & billing](/plans-and-billing/) for what each plan includes.
## Three kinds of hostname
[Section titled “Three kinds of hostname”](#three-kinds-of-hostname)
| Hostname | What it is | Plan |
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| Always-on domain | `.agents.…` — exists for every deployed agent, nothing to configure. The agent’s endpoint (invoke URL, `/readyz`) and the CNAME target for your custom domains. Serves the API, not the page. | Every plan |
| Slug host | A Guuey-owned name (`your-slug.agents.guuey.com`) — claimed automatically at first deploy (`-<4 characters>`); change it with `guuey slug claim`, which also reserves the portal path `/agent/`. Serves the standalone page — every deployed agent’s shareable address, with zero setup. | Every plan |
| Custom domain | A hostname you own: a subdomain pointed at the always-on domain with one CNAME, or a root domain with three records (see below). Serves the standalone page. | Pro / Scale |
Note
`guuey domains` is not the same thing as the **allowed origins** list (`guuey apps update --domains`), which controls which websites may embed your agent’s widget. A custom domain is where your agent is *served*; an allowed origin is where it may be *embedded*. See [Embed](/embed/) for the latter.
## Add a custom domain
[Section titled “Add a custom domain”](#add-a-custom-domain)
You can do this from the console or the CLI — both drive the same flow.
* **Console** — open your app and choose **Domains** in the sidebar. Copy the always-on domain (your CNAME target), or add a custom domain below it.
* **CLI** — `guuey domains add`, `list`, `verify`, `remove`; every subcommand accepts `--app-id ` if you’re outside a project directory.
### 1. Add the domain
[Section titled “1. Add the domain”](#1-add-the-domain)
```sh
guuey domains add chat.example.com
```
The response shows the record to create:
```plaintext
chat.example.com → CNAME → .agents.us-east-1.guuey.com
```
Root domains (`example.com`) are supported too, with a different set of records — see [Root (apex) domains](#root-apex-domains) below. Each app can have up to 4 custom domains.
### 2. Create the CNAME record
[Section titled “2. Create the CNAME record”](#2-create-the-cname-record)
At your DNS provider, create a CNAME from your subdomain to the target shown. Order doesn’t matter — you can create the record before or after adding the domain.
### 3. Verification runs by itself
[Section titled “3. Verification runs by itself”](#3-verification-runs-by-itself)
Guuey checks the record about once a minute for 7 days and verifies automatically as soon as the CNAME resolves. To check on demand, press **Verify now** in the console or run:
```sh
guuey domains verify chat.example.com
```
The command exits non-zero until the domain verifies, so it’s safe to use in scripts and CI.
### 4. TLS provisions, then it serves
[Section titled “4. TLS provisions, then it serves”](#4-tls-provisions-then-it-serves)
Once verified, a certificate is issued for your hostname — no TXT records, no certificate files, nothing to renew. The domain starts serving automatically, usually within minutes:
```plaintext
Domain chat.example.com verified — TLS is provisioning, usually live in minutes.
```
## Root (apex) domains
[Section titled “Root (apex) domains”](#root-apex-domains)
A root domain like `example.com` can’t hold a CNAME record — that’s a rule of DNS, not ours. So a root domain is verified and served differently:
```sh
guuey domains add example.com
```
Guuey detects a root domain automatically (a name that has its own SOA record). If your zone isn’t live yet, force it with `--txt`. The response lists **three** records to create instead of one:
```plaintext
_guuey-challenge.example.com → TXT → guuey-verify=.agents.us-east-1.guuey.com
example.com → ALIAS → .cloudfront.net
_cf-challenge.example.com → TXT → .cloudfront.net
```
1. **Ownership TXT** — what Guuey checks to verify you own the domain (the same 7-day window and **Verify now** as a subdomain).
2. **Root ALIAS** — where the domain actually serves from. Your DNS provider must support **ALIAS, ANAME, or CNAME flattening at the root** (Route 53, Cloudflare, DNSimple, DNS Made Easy, Namecheap and Porkbun do; many providers don’t). A plain A record won’t work — the edge’s addresses rotate.
3. **Edge certificate TXT** — lets the edge verify the domain and issue its certificate. Until this record resolves, the domain shows **Verified** but doesn’t serve; press **Check edge record** in the console (or run `guuey domains verify example.com` again) once it’s in place and serving converges within seconds instead of at the next hourly check.
**Recommended instead:** serve at `www.example.com` (one CNAME, like any subdomain) and forward `example.com` → `https://www.example.com` at your registrar — the standard setup for hosted services, and it works with every DNS provider.
**What’s different about a root domain, honestly:** a subdomain points at Guuey’s own hop, so if we ever move our edge, we repoint that hop and your DNS never changes. A root domain points straight at the edge, so if we ever move it, we’ll ask you to update records 2 and 3. We don’t plan to — but the difference is real, and it’s why `www` + forward is the recommendation.
## Statuses
[Section titled “Statuses”](#statuses)
`guuey domains list` and the console’s **Domains** page show two axes:
| Status | Meaning |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Pending | Waiting for the CNAME to resolve. The 7-day verification window is open. |
| Verified | Ownership proven; TLS provisioning begins. |
| Failed | The 7-day window elapsed before the CNAME resolved. Fix the record, then verify again — a fresh window opens. |
| TLS provisioning | Certificate being issued for the hostname. |
| Serving | Live — your domain serves the agent over HTTPS. |
| TLS failed | The certificate couldn’t issue. Most commonly a CAA record on your domain that doesn’t permit `amazon.com`; issuance retries automatically once fixed. |
## Troubleshooting
[Section titled “Troubleshooting”](#troubleshooting)
* **Verification never completes** — for a subdomain, confirm the CNAME with `dig CNAME chat.example.com`: it must answer with your app’s always-on domain. A proxied/flattened record (some providers rewrite CNAMEs) won’t verify. For a root domain, confirm the ownership TXT with `dig TXT _guuey-challenge.example.com`: it must contain the exact `guuey-verify=…` value shown.
* **A root domain is Verified but never starts serving** — the edge certificate TXT is missing: `dig TXT _cf-challenge.example.com` must answer with the edge endpoint. Create it, then press **Check edge record** (or run `guuey domains verify`). Also confirm the root ALIAS resolves: `dig A example.com` should return the edge’s addresses.
* **My DNS provider has no ALIAS/ANAME/flattening** — serve at `www` (one CNAME) and forward the root at your registrar; see [Root (apex) domains](#root-apex-domains).
* **TLS failed** — check for a `CAA` record on your domain or its parents; if one exists, it must permit `amazon.com` to issue.
* **The domain is claimed by another app** — a hostname can belong to one app at a time. If the existing claim never **verified**, you can take the domain over: point your DNS at the new app’s target (the CNAME for a subdomain, or the `_guuey-challenge` TXT for a root domain) and add it there — proof of ownership replaces the stale claim, immediately verified. If the domain is already **verified** on the other app, adding it is refused; remove it from that app first (`guuey domains remove`), then add it to the new one. This applies when moving a domain between your own apps, too.
* **After a plan downgrade** — custom domains **stop serving** within about an hour of the app leaving Pro or Scale; the slug host and always-on domain keep working. The domain rows are kept (shown as failed with plan-loss copy in the console). After upgrading back, serving resumes automatically at the next hourly check — your DNS record is still in place, so there is nothing to redo. (For a root domain, `guuey domains verify` can trigger re-promotion right away.) Pending domains can’t be promoted while the app is off Pro or Scale.
## Who signs in on your domain?
[Section titled “Who signs in on your domain?”](#who-signs-in-on-your-domain)
By default, visitors on your custom domain chat as guests. If your app uses Guuey sign-in, visitors get Guuey’s login. If it uses bring-your-own auth (configured under **Users → Authentication**), visitors sign in either through your [identity endpoint](/identity-endpoint/) (set under **Settings → General → Standalone page**; custom domains only) or through a redirect to [your own identity provider](/page-sign-in/) (any host the page is served on, including the slug host and the share page) — the full table is on [Sign-in on the agent’s page](/page-sign-in/).
## The standalone page
[Section titled “The standalone page”](#the-standalone-page)
What your custom domains, your slug host and the first-party share page serve is the app’s **standalone page** — one page policy for all of them, under **Settings → General → Standalone page** (or `guuey apps update`). The always-on domain is not one of these hosts: it answers the agent’s API and nothing else. Since every deployed agent gets a slug host at its first deploy, the share page is a second door rather than the only one; an agent whose slug was released (`guuey slug release`) is reachable by people only through its share page until a slug is claimed again.
* **On / off** — turn the page off and every standalone host shows “This agent doesn’t have a public page.” Embeds, the Portal listing and the agent keep working; only the page is hidden.
* **Welcome line** — the sentence shown before the first message, and the share-link description. The page’s title is always the app’s name.
* **Call-to-action** — an optional button under the welcome line (label + https link, opens in a new tab). Set both or neither.
* **Hide from search engines** — adds `noindex, nofollow`; anyone with the link can still open the page.
* **Identity endpoint** — see above.
```bash
guuey apps update --page on --welcome-copy "Ask me about shipping." \
--cta-label "Book a demo" --cta-url https://www.example.com/demo --noindex off
```
# Embed & share
> Put your agent on your own website with one script tag (a second line only when you want options), or share it as a hosted chat page — anonymous or signed-in, always on your allowed domains.
A deployed agent can meet people two ways: embedded as a chat widget on your own website, or shared as a hosted chat page — no website required.
## Embed on your site
[Section titled “Embed on your site”](#embed-on-your-site)
Any deployed agent can live on your own website as a chat widget. The widget runs in an iframe on guuey’s origin, so it never touches your page’s cookies, storage, or scripts — the only thing you add to your site is two `
```
The loader reads `data-app` from its own tag and mounts — nothing else to write. When you want options (mode, theme, launcher, colour, position, a sign-in token endpoint), the console renders the two-script form instead — the one exception is the bar layout, which the single tag carries as `data-layout="bar"`:
```html
```
Paste it anywhere in your HTML — before `