Quiet-Room Scheduler

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

ZGuard audits a room once the conversation goes quiet, and the Arvist promotes what the sitting taught into durable memory a little later. Update Agent (the live home room) is the exception: after Distil lands, Arvist promotes the host sitting immediately so the next z-link is not waiting on this timer (ZLY-1897). The quiet chain remains the catch-up if that per-turn run fails. 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 starts a chain by arming only the ZGuard timer. Arming writes a row in z_agent_room_watch and upserts a one-time EventBridge schedule named zguard-<roomId>. When that callback runs, Guard waits for Distil's lease, claims it, and only then audits — Distil and Guard never write the same room in parallel. If Distil is still in flight after a full lease, Guard re-arms instead of racing it. Once ZGuard has completed and applied its effects, the app always arms Arvist and creates zarvist-<roomId>, whether or not ZGuard generated a message. This is a real dependency: an Arvist timer does not exist while its ZGuard run is still pending or running. If a participant replies before Arvist fires, that reply starts a fresh ZGuard chain and the pending Arvist timer is cleared.

On a live home room, Distil already called Arvist for the host (scoped since the last successful promotion). A later quiet-chain Arvist then usually finds nothing new and skips the model call.

An onboarding interview never starts this chain, and the agent never reaches out on its own there: during the interview it only answers the person. Demo rooms and self-chat deals are exempt the same way.

Agent-to-agent wakes are different from a planner messages[] push: after a sub-agent (or parent) finishes a hidden inbound turn, the room does arm Guard and disarm Arvist, so a talent home that quoted a price still gets the quiet-chain refresh of identity.agentBrief. See Agent-to-agent messaging.

When either schedule fires it invokes a tiny Lambda whose only job is to POST { roomId, kind } to the app's /api/z-agent/guard/callback, and then it deletes itself.

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.

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_ARVIST_DEBOUNCE_MSoptional; default 600000 (10 min total quiet target). The post-Guard delay is this value minus the Guard target, with a 1-second minimum.
Z_AGENT_WATCH_STALE_AFTER_MSoptional; default 3600000 (1 hour). A missing timer older than this is classified as stale and removed by reconciliation.

The app also needs CRON_SECRET (already set) and 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"}'

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.

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.