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.
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.
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_WATCH_STALE_AFTER_MS | optional; default 3600000 (1 hour). A missing timer older than this is classified as stale and removed by reconciliation. |
NEXT_PUBLIC_APP_URL | required for the Arvist kick; the fire-and-forget POST is sent here |
CRON_SECRET | required; 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.
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>"}'
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.
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.