# 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_room` is 1:1 with `instances`). 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/:webhookToken
```

The 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.

The classifier is asked for a bare "yes" or "no", and the engine reads only the first word of its answer, after stripping markdown, quotes, punctuation and a leading label. So `Yes.`, `**yes**`, `"yes"`, `Answer: yes`, `sì` and `si` are all a match (Italian is accepted because a criterion written in Italian pulls the reply into it). An answer that is neither yes nor no, such as a hedge or a reply that argues before deciding, counts as no match for that definition, and the engine logs a warning naming the definition. Without that warning, a classifier that did not comply would look exactly like an event nobody was interested in: the event is dropped with no backlog row.

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:

1. Load all enabled rooms and the count of pending events per instance.
2. For each room with at least one pending event **and** not currently running, mark it as running and spawn an async cycle.
3. The cycle (`executeRoomCycle`) builds a synthetic message containing the pending events plus their interpretation prompts (plus any human reply if `triggerImmediate` was used), invokes the standard supervisor, and sends the result to the configured outbound channel. The room's own prompt rides along as the `## Room Mandate` block of the system prompt (see [Prompt](https://docs.polyant.ai/concepts/prompt#also-in-the-prefix-when-applicable)).

   The payload in that message is trimmed for the prompt, not for storage. For a GitHub event source, the API URL templates every GitHub object carries (`events_url`, `stargazers_url` and the like), `url`, `node_id`, `gravatar_id`, `avatar_url` and `performed_via_github_app` are dropped from the rendered copy, while `html_url`, the one link an agent writes into a reply, is kept. The synthetic message is re-sent on every step of the cycle, so that noise was paid for several times per cycle. Other source types pass through unchanged, and the raw payload stays intact in `event_backlog` for replay and debugging.

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_definitions` link, `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 format `room:{instanceId}:{ts}`.
- `packages/engine/src/room/payload-projection.ts` - Trims known payload noise (GitHub) from the copy rendered into the cycle.
- `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 for `POST /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 (tier `fast`, priority-ordered, first match wins, decorated verdicts read by their first word).
- `packages/engine/src/webhooks/webhook-backlog.store.ts` - `event_backlog` queue, `BACKLOG_CAP = 100`.
- `packages/engine/src/webhooks/webhook-sources.store.ts` - `event_sources` + `event_definitions` CRUD with encrypted config.
- `packages/engine/src/webhooks/template-renderer.ts` - Renders the matched definition's `interpretationPrompt` (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.

## See also

- [Agents](https://docs.polyant.ai/concepts/agents)
- [Channels](https://docs.polyant.ai/concepts/channels)
- [Tools](https://docs.polyant.ai/concepts/tools)
- [Architecture](https://docs.polyant.ai/concepts/architecture)
- [Room (admin panel)](https://docs.polyant.ai/admin-panel/room) - enabling it and configuring its event sources
- [Webhooks reference](https://docs.polyant.ai/reference/webhooks) - the inbound contract
- [Connect HubSpot](https://docs.polyant.ai/how-to/connect-hubspot) - a worked inbound integration
