Quiet-Room Scheduler

The AWS alarm clock that wakes ZGuard, and how to redeploy it

ZGuard audits a room once the conversation goes quiet. After that audit finishes, the app kicks the Arvist in a separate request so it can promote what the sitting taught into durable memory without sharing ZGuard's request budget or the room's memory queue. A room running a mock tape never promotes: the Arvist records a skipped run and leaves the account's durable memory alone, so scripted fiction cannot become standing knowledge. Nothing in a web request can wait a minute to find out whether a room went quiet, so the waiting happens in AWS.

How it works

After every persona reply the app arms the ZGuard timer. Arming writes a row in z_agent_room_watch and upserts a one-time EventBridge schedule named zguard-<roomId>. When the schedule fires it invokes a tiny Lambda whose only job is to POST { roomId, kind: "guard" } to the app's /api/z-agent/guard/callback, and then it deletes itself.

The callback claims the watch, runs ZGuard, and — once the room's memory queue slot is released — fires a non-awaited POST to /api/z-agent/arvist/run (authenticated with CRON_SECRET). The parent holds the invocation open for three seconds so the kick actually leaves before Vercel freezes the request. Arvist then re-reads the room from Postgres and promotes each bound participant's sitting into durable account memory.

The Postgres row is the authority; AWS is only the alarm clock. Before running anything the callback claims the watch row, so a timer that fires after someone spoke again, fires twice, or fires early claims nothing and does nothing. That is what makes the whole path safe to retry and safe to trigger by hand.

sequenceDiagram participant App as App (Vercel) participant DB as Postgres participant EB as EventBridge Scheduler participant L as Lambda App->>DB: arm Guard watch App->>EB: upsert zguard-roomId EB->>L: Guard schedule fires L->>App: POST callback (guard) App->>DB: claim Guard watch App->>App: run ZGuard to completion App->>App: fire-and-forget POST /arvist/run App->>App: sleep 3s so the kick leaves App->>App: Arvist re-reads room, promotes durable memory

What is deployed

Everything lives in AWS account 533267257504, region us-east-2. The region is not a free choice: the app builds its scheduler client from the ambient AWS_REGION of the Vercel project, and a schedule can only invoke a function in its own region.

EnvironmentAppLambdaSchedule group
devdev.zooly.aizguard-callback-devzguard-dev
stagestage.zooly.aizguard-callback-stagezguard-stage
productionapp.zooly.aizguard-callback-prodzguard-prod

One function per environment rather than one shared function: each carries its own callback secret and its own pinned callback URL, so a dev timer can never reach production, and each environment's schedules can be listed or purged on their own.

Two IAM roles are shared across the three: zguard-lambda-role (logging only) and zguard-scheduler-role, which EventBridge assumes to invoke zguard-callback-*.

Redeploying

From packages/z-agent/srv/infra/guard-lambda:

./deploy.sh                  # all three environments
./deploy.sh dev              # one environment
./deploy.sh prod --code-only # ship index.mjs, touch nothing else
./deploy.sh all --vercel     # also write the Z_AGENT_GUARD_* vars to Vercel

The script is idempotent — it creates what is missing and updates what exists, so running it twice is the same as running it once. It needs the aws CLI on the zooly profile and, unless you export CRON_SECRET yourself, a logged-in vercel CLI: the Lambda's callback secret must equal the app's CRON_SECRET, and the script reads it straight from the environment's Vercel project so the two cannot drift.

Use --code-only for the common case of an index.mjs change. Use --vercel only when an ARN or a group name changed; it rewrites four variables and the app has to be redeployed before it sees them.

App configuration

--vercel sets these on the production and preview targets of each project. Development is deliberately left unset: a laptop that pulled these values would start creating real AWS timers.

VariableValue
Z_AGENT_GUARD_LAMBDA_ARNarn:aws:lambda:us-east-2:533267257504:function:zguard-callback-<env>
Z_AGENT_GUARD_SCHEDULER_ROLE_ARNarn:aws:iam::533267257504:role/zguard-scheduler-role
Z_AGENT_GUARD_CALLBACK_URLhttps://<host>/api/z-agent/guard/callback
Z_AGENT_GUARD_SCHEDULE_GROUPzguard-<env>
Z_AGENT_GUARD_DEBOUNCE_MSoptional; default 60000 (1 min)
Z_AGENT_WATCH_STALE_AFTER_MSoptional; default 3600000 (1 hour). A missing timer older than this is classified as stale and removed by reconciliation.
NEXT_PUBLIC_APP_URLrequired for the Arvist kick; the fire-and-forget POST is sent here
CRON_SECRETrequired; authenticates both the Guard callback and the Arvist kick

The app also needs AWS credentials allowed to create, update and delete schedules in the group plus iam:PassRole on the scheduler role.

Local development needs none of this

Without Z_AGENT_GUARD_LAMBDA_ARN the app writes the watch row, logs that scheduling was skipped, and behaves identically in every other respect. Fire the timer yourself with the playground's "Run ZGuard now" button, or:

curl -X POST http://localhost:3000/api/z-agent/guard/callback \
  -H "Authorization: Bearer $CRON_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"roomId":"<roomId>","kind":"guard"}'

That callback will kick /api/z-agent/arvist/run if NEXT_PUBLIC_APP_URL and CRON_SECRET are set. You can also trigger Arvist directly:

curl -X POST http://localhost:3000/api/z-agent/arvist/run \
  -H "Authorization: Bearer $CRON_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"roomId":"<roomId>"}'

Checking on it

AWS="aws --profile zooly --region us-east-2"

# What is currently pending for an environment
$AWS scheduler list-schedules --group-name zguard-prod

# Did the Lambda run, and what did the app answer
$AWS logs tail /aws/lambda/zguard-callback-prod --since 1h

# Is the callback alive? 401 is the healthy answer to an unauthenticated POST
curl -s -o /dev/null -w '%{http_code}\n' -X POST \
  https://app.zooly.ai/api/z-agent/guard/callback -d '{}'

An empty schedule list is normal — schedules exist only between a reply and its debounce, and delete themselves once they fire. Only Guard timers are created; there is no zarvist-<roomId> schedule.

Repairing DB/AWS drift

The Admin Agent → Schedules tab classifies every pair before offering a repair:

  • healthy — the watch row and EventBridge timer both exist;
  • missing timer — the watch is still valid and reconciliation recreates its timer;
  • stale watch — the timer is missing and the watch is overdue beyond Z_AGENT_WATCH_STALE_AFTER_MS, so reconciliation removes the row;
  • replay only — scripted rooms intentionally suppress AWS timers and are never repaired;
  • orphan timer — EventBridge has a timer without an authoritative watch row, so reconciliation removes the timer.

Each repair is deliberately scoped to one row. The tab offers Recreate timer, Delete stale watch, or Delete orphan timer only when that exact action is valid. After confirmation, the server recalculates the current state and refuses the request if the row became healthy or otherwise changed.

If a callback fails the Lambda throws and EventBridge retries twice within the hour before giving up. The failure is worth a look rather than a day of retries, which is why the app sets that retry policy explicitly; the AWS default is 185 attempts over 24 hours.