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.
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.
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.
| Environment | App | Lambda | Schedule group |
|---|---|---|---|
| dev | dev.zooly.ai | zguard-callback-dev | zguard-dev |
| stage | stage.zooly.ai | zguard-callback-stage | zguard-stage |
| production | app.zooly.ai | zguard-callback-prod | zguard-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-*.
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.
--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.
| Variable | Value |
|---|---|
Z_AGENT_GUARD_LAMBDA_ARN | arn:aws:lambda:us-east-2:533267257504:function:zguard-callback-<env> |
Z_AGENT_GUARD_SCHEDULER_ROLE_ARN | arn:aws:iam::533267257504:role/zguard-scheduler-role |
Z_AGENT_GUARD_CALLBACK_URL | https://<host>/api/z-agent/guard/callback |
Z_AGENT_GUARD_SCHEDULE_GROUP | zguard-<env> |
Z_AGENT_GUARD_DEBOUNCE_MS | optional; default 60000 (1 min) |
Z_AGENT_ARVIST_DEBOUNCE_MS | optional; 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_MS | optional; 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.
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"}'
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.
The Admin Agent → Schedules tab classifies every pair before offering a repair:
Z_AGENT_WATCH_STALE_AFTER_MS, so reconciliation removes the row;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.