Agents as code
read as.mdYour 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”guuey.json is the whole manifest. The agent section is what the pod runs
(framework, model, prompt, MCP servers — see the CLI page); the
app.access block declares the app record’s access policy, using the
same field names as the platform API:
{ "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 } }}accessis PUT-per-field: a field you don’t mention is never reset."userAuthConfig": nullis the explicit “clear the issuer binding”. Values are validated exactly likeguuey apps update(userAuthMode∈anonymous | native_pool | byo;allowedDomainsare 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); a CI credential never carries
it. Live knobs that are not build material (
maxPods, runtime updates) stay onguuey agent config— see Hosting for scaling and runtime-update semantics. - The
agent.systemPromptfile reference is resolved by the CLI: it sends the file’s bytes alongside the document and Guuey inlines them byte-exact.
Built the agent in Studio first? guuey pull ejects the 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.
guuey agent apply
Section titled “guuey agent apply”Run it from the directory that holds guuey.json:
guuey agent apply✓ 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/statusWhat one apply does, in order:
- Validates the document with the same schema and lints as
guuey deploy(secrets in headers, reserved MCP names, tool-gate grammar, …). - Computes the plan: does the assembled snapshot byte-match the newest
active build, and does every declared
accessfield already hold? - 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. - Otherwise converges the differing
accessfields (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 <p> |
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. <org/repo>@<sha>[:<path>]: explicit. |
--json |
Emit the response verbatim (see below). |
--app-id <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 --json response is the contract your pipeline gates on:
{ "applied": true, "unchanged": false, "dryRun": false, "appId": "7365f90e-…", "buildNumber": 12, "status": "queued", "deployedContentHash": { "agentDef": "<sha256 of your guuey.json bytes>", "systemPrompt": "<sha256 of your prompt file bytes>", "snapshot": "<sha256 of the persisted snapshot — what the pod boots from>" }, "provenanceRecorded": true, "statusPath": "/v1/apps/7365f90e-…/deployments/12/status", "plan": { "snapshot": "changed", "config": [{ "field": "guestAccess", "current": null, "desired": false }] }}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.
guuey agent status
Section titled “guuey agent status”guuey agent status 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: falseAdd --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, Studio) 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 <n>
Section titled “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):
guuey agent rollback --to 7 --wait✓ 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 <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”Do not put a personal login in CI. Mint an app-scoped service token once, as the app’s owner:
guuey tokens create --label "github-actions" --app-id <appId>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 <tokenId> 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”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/<workspaceId>/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”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.
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 <org>/<repo>@<sha> (agents/helper).
The API, if you’d rather not run the CLI
Section titled “The API, if you’d rather not run the CLI”Everything above is one route:
POST /v1/apps/:appId/reconcile Authorization: Bearer <service token | workspace API key | personal key>{ "artifacts": { "guueyJson": "<document string>", "systemPrompt": "<prompt bytes, optional>" }, "config": { … same fields as app.access — optional; when absent, app.access in the document is used }, "provenance": { "repo": "org/repo", "path": "agents/helper", "sha": "<git sha>" }, "deploymentId": "<optional client-supplied id>", "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, 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”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”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).