Room
A Room turns an instance from a reactive chatbot into a proactive worker. With a room enabled, the agent runs on its own clock: every 30 seconds the engine checks for pending external events, classifies each one against the room’s event definitions, and triggers a fresh supervisor cycle for every match. The agent reacts to outside events — a HubSpot deal stage change, a Stripe invoice paid, a CI build failed — without anyone writing a chat message first.
This page covers what makes a room event-driven (not conversational), how webhooks become events, how the scheduler ticks, and how the activity log self-compacts over time.
Event-driven, not conversational
A room is fundamentally different from a normal conversation:
- No persistent thread. Every room cycle creates a brand-new conversation with the ID
room:{instanceId}:{Date.now()}. There is no “the room’s conversation” — there are as many room conversations as the room has run cycles. - No human in the loop by default. A cycle fires because the backlog has pending events, not because a human typed something. The agent decides what (if anything) to communicate outwards.
- Memory is intentionally disabled per cycle. Because every cycle is a fresh conversation, the long-term memory store would either be flooded with low-value extraction artefacts or be useless across cycles. Room cycles bypass memory extraction.
- Each instance has exactly one room (
instance_roomis 1:1 withinstances). The room row stores the room’s mandate prompt and the outbound channel for replies.
When a human does reply on the outbound channel (e.g. answers a Slack notification the agent sent), the room scheduler routes that reply into an immediate cycle via triggerImmediate() rather than waiting for the next tick.
Webhooks become events
External systems deliver events via webhooks:
POST /webhooks/:webhookTokenThe receiver always returns 200 OK immediately — processing is fire-and-forget. Payloads are capped at 64 KB. The endpoint is keyed by webhookToken (one token per event source), not by instance slug, so URLs are unguessable and rotatable.
For each delivery the engine walks a validation cascade: token resolves → source enabled → host room enabled → backlog under cap → at least one event definition exists. If everything passes, the payload is handed to the webhook matcher.
LLM-driven matching, not a predicate DSL
Polyant deliberately avoids a JSON predicate DSL (“if payload.deal.stage == "closedwon" then …”). Instead, each event definition is a pair of natural-language prompts:
matchingPrompt— a description of what to match. Example: “Match when a HubSpot contact changes lifecycle stage to customer.”interpretationPrompt— a description of what to do once matched. Injected into the next supervisor cycle.
A fast-tier LLM evaluates each definition’s matching prompt against the payload, in priority order, and returns the first match (no further definitions are evaluated). This means:
- Operators write rules in plain English. No DSL syntax to learn.
- Order matters — put specific rules above general ones.
- Matching is sequential by design: a single LLM call per definition until one says yes, rather than a parallel fan-out that wastes tokens.
Matched events are inserted into the event_backlog table with status = pending. The backlog has a hard cap of 100 pending events per instance — when the cap is reached, new events are dropped (not queued indefinitely), preventing an upstream burst from drowning the agent.
The 30-second tick
The room scheduler is a singleton with a 30-second tick:
- Load all enabled rooms and the count of pending events per instance.
- For each room with at least one pending event and not currently running, mark it as running and spawn an async cycle.
- The cycle (
executeRoomCycle) builds a synthetic message containing the pending events plus their interpretation prompts (plus any human reply iftriggerImmediatewas used), invokes the standard supervisor, and sends the result to the configured outbound channel.
Per-room mutex (the running Set) ensures the same room never processes two cycles concurrently — but different rooms run in parallel, so a slow cycle for instance A does not stall instance B.
Once a day, the scheduler also runs activity-log housekeeping (see below).
This is not the same as the scheduleTask tool. scheduleTask is a cron-style scheduler that fires arbitrary tool calls on time-based triggers (e.g. “send the weekly summary every Monday at 9 AM”). Rooms are event-triggered. The two systems coexist and serve different needs.
Activity log compaction
Every notable action a room takes — events matched, messages sent, tools called, errors hit — is recorded in room_activity_log with a log_type of daily, weekly, or monthly. Daily compaction runs once per 24-hour window:
- Daily entries older than 7 days are merged into weekly entries.
- Weekly entries older than 28 days are merged into monthly entries.
- Monthly entries older than 12 months are deleted outright.
This time-decaying chronicle lets the admin panel show fine-grained recent activity without unbounded storage growth.
How it works
+----------------------+ +----------------------+
| HubSpot / Stripe / | | Other system |
| anything | | |
+----------+-----------+ +----------+-----------+
| |
v v
+------------------------------+
|
v
+-----------------------------------------------+
| POST /webhooks/:webhookToken (always 200 OK) |
+-----------------------+-----------------------+
|
v
+---------------+---------------+
| Validation cascade |
| token -> source -> room |
| -> backlog cap (<100) |
| -> any definition? |
+---------------+---------------+
|
v
+---------------+---------------+
| Webhook matcher (tier=fast) |
| for each definition (priority|
| order): "match? yes/no" |
| -- first match wins |
+---------------+---------------+
|
v
+---------------+---------------+
| event_backlog (status=pending)|
+---------------+---------------+
|
| (every 30s)
v
+---------------+---------------+ +---------------------------+
| Room scheduler tick | | human reply on outbound |
| - listEnabledRooms() | | channel -> triggerImmediate
| - countPendingByInstance() | +-------------+-------------+
| - per-room mutex | |
+---------------+---------------+ |
| |
v |
+-------------------------------+ <------------------+
| executeRoomCycle |
| conv id: room:{id}:{ts} |
| synthetic msg = pending |
| events + interpretations |
| -> Supervisor (memory off) |
+---------------+---------------+
|
v
+-------------------------------+
| Outbound channel |
| (Slack / Telegram / WhatsApp) |
+-------------------------------+Code reference
packages/engine/src/room/room.schema.ts—instance_room,event_definitionslink,room_activity_log.packages/engine/src/room/room-scheduler.ts— Singleton scheduler, 30-second tick (TICK_INTERVAL_MS), per-room mutex,triggerImmediate().packages/engine/src/room/room-engine.ts—executeRoomCycle(); conv ID formatroom:{instanceId}:{ts}.packages/engine/src/room/activity-log.store.ts— Compaction (compactActivityLog): 7d daily → 4w weekly → 12m monthly → delete.packages/engine/src/server/webhooks/webhook.controller.ts— HTTP receiver forPOST /webhooks/:webhookToken(always returns 200 OK; fire-and-forget dispatch into the webhook engine).packages/engine/src/webhooks/webhook-engine.ts— Central dispatch and queue management.packages/engine/src/webhooks/webhook-matcher.ts— LLM-based event matching (tierfast, priority-ordered, first match wins).packages/engine/src/webhooks/webhook-backlog.store.ts—event_backlogqueue,BACKLOG_CAP = 100.packages/engine/src/webhooks/webhook-sources.store.ts—event_sources+event_definitionsCRUD with encrypted config.packages/engine/src/webhooks/template-renderer.ts— Renders the matched definition’sinterpretationPrompt(with payload variables) into the synthetic message handed to the supervisor.packages/engine/src/webhooks/active-triggers.ts— Tracks trigger state across cycles (which triggers are armed/cooling-down) so an event source can’t refire while a prior interpretation is still in flight.packages/engine/src/agents/tools/room-mark-completed.tool.ts— Harness tool the agent uses to drain the backlog.