---
title: "Agents as code"
description: "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

`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.
- 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.

Built the agent in [Studio](/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.

:::caution[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. Studio says so where it matters: an agent whose latest build
came from `apply` carries a **Managed by `<repo>`** chip, its Settings
drawer notes the repo and sha, and the first hand-edit asks _Edit anyway?_
— the sign-in and allowed-origins controls carry the same note when the
manifest declares `app.access`. Deploying a hand-edit from Studio makes
that build the latest (no provenance, so the chip drops) until the next
`apply` restores the repo's bytes.
:::

## `guuey agent apply`

Run it from the directory that holds `guuey.json`:

```bash
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/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 <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

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": "<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`

```bash
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:    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`, 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>`

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
```

```
✓ 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

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

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

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 <org>/<repo>@<sha> (agents/helper)`.

## 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](/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

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

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).