|
| 1 | +# Number Cruncher: provisioning and setup |
| 2 | + |
| 3 | +How to stand up the Number Cruncher (NC) bot as its own Slack app and wire it to an |
| 4 | +llm_engine instance, both for local development and for production. NC checks one or more |
| 5 | +LLM API budget endpoints on a configurable schedule and posts an alert to a Slack channel |
| 6 | +whenever any budget exceeds its configured threshold. |
| 7 | + |
| 8 | +NC is a proactive periodic agent anchored to a long-lived Conversation, provisioned from |
| 9 | +the `numberCruncher` conversation type. It uses the per-bot Slack identity: its webhook is |
| 10 | +routed at `/v1/webhooks/slack/:appKey` and its signing secret lives on its own Adapter row, |
| 11 | +not the global env var. That per-bot isolation lets a dev NC and a prod NC coexist in the |
| 12 | +same Slack workspace without colliding: different `appKey`s, different Adapter rows, and |
| 13 | +different Conversations in different databases. |
| 14 | + |
| 15 | +## How the pieces fit (read once) |
| 16 | + |
| 17 | +- **The Slack app** holds the bot token, signing secret, scopes, and a single Event |
| 18 | + Subscriptions Request URL. That URL points at exactly one server, so one Slack app cannot |
| 19 | + serve both your laptop and production at the same time. Use a separate Slack app per |
| 20 | + environment (see "Moving to production"). |
| 21 | +- **The webhook host** is wherever the llm_engine server is reachable over HTTPS. The |
| 22 | + Slack Request URL is `https://<that host>/v1/webhooks/slack/<appKey>`. |
| 23 | +- **The Adapter row + Conversation** are created in the target database by the provisioning |
| 24 | + call below. Local provisioning writes to your dev DB; production provisioning writes to |
| 25 | + the production DB. Nothing is migrated between them — re-provision per environment. |
| 26 | +- **Secrets** (bot token, signing secret, budget API keys) are passed into the provisioning |
| 27 | + call and stored on the Adapter/Conversation rows in that environment's DB, not in `.env`. |
| 28 | + The global `SLACK_SIGNING_SECRET` env var is only a fallback for bots that do not set |
| 29 | + their own. |
| 30 | +- **Budget checks** are purely outbound. NC never needs to receive Slack events. The |
| 31 | + Request URL only needs to exist so Slack can verify it; you do not need to subscribe to |
| 32 | + any message events. |
| 33 | + |
| 34 | +--- |
| 35 | + |
| 36 | +## Part A: Create NC's Slack app |
| 37 | + |
| 38 | +Do this once per environment (one app for dev, one for prod). |
| 39 | + |
| 40 | +1. Create a new Slack app in your workspace. NC is a separate app so it shows up as its |
| 41 | + own bot user (name, avatar, permissions). Name them distinctly per environment, e.g. |
| 42 | + "Number Cruncher (dev)" and "Number Cruncher". |
| 43 | +2. Request bot token scopes. NC only posts messages, so it needs only **`chat:write`**. |
| 44 | +3. Install the app to the workspace and copy the **Bot User OAuth token** (`xoxb-...`). |
| 45 | + Make sure it is the Bot token, not the User token. |
| 46 | +4. From Basic Information, copy the **Signing Secret**. |
| 47 | +5. Note the **workspace (team) ID** (`T...`) and the **channel ID** of NC's alert channel |
| 48 | + (`C...` or `G...`). |
| 49 | +6. Invite the bot to its alert channel: `/invite @YourNumberCruncherBot`. NC can only post |
| 50 | + to channels it is a member of. |
| 51 | + |
| 52 | +Leave the Event Subscriptions Request URL for Part C. |
| 53 | + |
| 54 | +--- |
| 55 | + |
| 56 | +## Part B: Provision NC in llm_engine (BEFORE setting the Request URL) |
| 57 | + |
| 58 | +This step creates NC's Adapter row and Conversation. Run it against the target instance's |
| 59 | +API. |
| 60 | + |
| 61 | +Pick an `appKey`: a short unique slug that becomes the last path segment of NC's webhook |
| 62 | +URL. Use distinct keys per environment, e.g. `nc-dev` locally and `nc` in production. |
| 63 | + |
| 64 | +Provisioning must happen before Slack verifies the Request URL, because URL verification |
| 65 | +hits `/v1/webhooks/slack/<appKey>` and the handler looks up the adapter by `appKey` to |
| 66 | +validate the challenge. If you set the Request URL first, Slack reports "Your URL didn't |
| 67 | +respond with the value of the challenge parameter." |
| 68 | + |
| 69 | +`POST <API host>/v1/conversations/from-type` with an admin bearer token: |
| 70 | + |
| 71 | +```json |
| 72 | +{ |
| 73 | + "type": "numberCruncher", |
| 74 | + "name": "Number Cruncher", |
| 75 | + "platforms": ["slack"], |
| 76 | + "properties": { |
| 77 | + "slackChannel": "<channel ID, C... or G...>", |
| 78 | + "slackWorkspace": "<workspace ID, T...>", |
| 79 | + "slackBotToken": "<xoxb-... bot token>", |
| 80 | + "slackBotUserId": "<bot user ID, U...>", |
| 81 | + "slackSigningSecret": "<app signing secret>", |
| 82 | + "slackAppKey": "nc-dev", |
| 83 | + "botName": "Number Cruncher", |
| 84 | + "checkInterval": "3600", |
| 85 | + "budgets": [ |
| 86 | + { |
| 87 | + "label": "AWS Bedrock", |
| 88 | + "endpoint": "https://api.example.com/budget/bedrock", |
| 89 | + "apiKey": "<your-api-key>", |
| 90 | + "thresholdPercent": 80 |
| 91 | + }, |
| 92 | + { |
| 93 | + "label": "OpenAI", |
| 94 | + "endpoint": "https://api.example.com/budget/openai", |
| 95 | + "apiKey": "<your-api-key>", |
| 96 | + "thresholdPercent": 80 |
| 97 | + } |
| 98 | + ] |
| 99 | + } |
| 100 | +} |
| 101 | +``` |
| 102 | + |
| 103 | +**`budgets`** is an array of budget configurations. Each entry requires: |
| 104 | +- `label` — display name shown in the alert (e.g. `"AWS Bedrock"`) |
| 105 | +- `endpoint` — URL that returns `{ "quota": { "limit": "250.0" }, "remaining_limit": "199.40" }` |
| 106 | +- `apiKey` — sent as `Authorization: Bearer <apiKey>` on each request |
| 107 | +- `thresholdPercent` — integer 0–100; NC alerts when `(limit - remaining) / limit * 100 >= thresholdPercent` |
| 108 | + |
| 109 | +**`checkInterval`** is in seconds. Defaults to `86400` (24 hours). Set to `3600` for hourly |
| 110 | +checks. The value is baked into `triggers.periodic.timerPeriod` on the agent at creation |
| 111 | +time. |
| 112 | + |
| 113 | +`slackBotUserId` and `slackSigningSecret` are optional in the type definition but recommended: |
| 114 | +the bot user ID prevents NC from reacting to its own Slack messages, and the signing secret |
| 115 | +gives NC its own per-bot identity for webhook validation. |
| 116 | + |
| 117 | +--- |
| 118 | + |
| 119 | +## Part C: Point Slack at NC's webhook and verify |
| 120 | + |
| 121 | +Now that NC's Adapter row exists: |
| 122 | + |
| 123 | +1. Make the llm_engine server reachable over HTTPS. |
| 124 | +2. In the Slack app's Event Subscriptions, set the Request URL to |
| 125 | + `https://<host>/v1/webhooks/slack/<appKey>`. Slack sends a challenge; the handler |
| 126 | + echoes it and the URL turns verified. |
| 127 | +3. NC does not need to subscribe to any message events. The Request URL only needs to |
| 128 | + verify. Leave event subscriptions empty. |
| 129 | + |
| 130 | +--- |
| 131 | + |
| 132 | +## Local development specifics |
| 133 | + |
| 134 | +- Run the server with `yarn run dev` (defaults to `PORT=3000`). |
| 135 | +- Expose it over HTTPS with an ngrok tunnel to port 3000. The tunnel URL is your webhook |
| 136 | + host: `https://<subdomain>.ngrok-free.app/v1/webhooks/slack/nc-dev`. ngrok URLs change |
| 137 | + on restart unless you have a reserved domain, so you may need to re-set the Request URL |
| 138 | + after restarting the tunnel. |
| 139 | +- Provision against `http://localhost:3000` (Part B) with a dev admin token. |
| 140 | +- To trigger a check immediately without waiting for the schedule, use |
| 141 | + `POST /v1/messages` (see "Smoke test" below). |
| 142 | + |
| 143 | +## Production specifics |
| 144 | + |
| 145 | +- The webhook host is the deployed llm_engine domain. No tunnel. |
| 146 | +- `MONGODB_URL` points at the production database. |
| 147 | +- Secrets (bot token, signing secret, budget API keys) go in the provisioning call, stored |
| 148 | + on the production Adapter/Conversation rows. Keep them out of source control. |
| 149 | + |
| 150 | +--- |
| 151 | + |
| 152 | +## Moving from local to production |
| 153 | + |
| 154 | +There is no data migration — re-run the same setup against the production instance: |
| 155 | + |
| 156 | +1. **Create a second Slack app for production** (Part A). A Slack app has one Event |
| 157 | + Subscriptions Request URL, so the dev app and the prod app must be separate apps. They |
| 158 | + can live in the same workspace; the per-bot `appKey` keeps their webhooks distinct. |
| 159 | +2. **Provision against the production API** (Part B) with a production admin token, the |
| 160 | + prod Slack app's bot token and signing secret, and a distinct `appKey` (e.g. `nc`). |
| 161 | +3. **Set the prod app's Request URL** to `https://<deployed domain>/v1/webhooks/slack/nc` |
| 162 | + and verify (Part C). |
| 163 | + |
| 164 | +### Updating an existing conversation |
| 165 | + |
| 166 | +To change the bot token, signing secret, budget list, or check interval on an existing |
| 167 | +provisioned NC, you do not need to re-provision. `PUT /v1/conversations` with only the |
| 168 | +changed properties: |
| 169 | + |
| 170 | +```json |
| 171 | +{ |
| 172 | + "id": "<NC conversation id>", |
| 173 | + "properties": { |
| 174 | + "checkInterval": "7200", |
| 175 | + "budgets": [ |
| 176 | + { |
| 177 | + "label": "AWS Bedrock", |
| 178 | + "endpoint": "https://api.example.com/budget/bedrock", |
| 179 | + "apiKey": "<updated-key>", |
| 180 | + "thresholdPercent": 75 |
| 181 | + } |
| 182 | + ] |
| 183 | + } |
| 184 | +} |
| 185 | +``` |
| 186 | + |
| 187 | +Only the keys you send change. The conversation must be inactive (NC's Conversation always |
| 188 | +is, since it never starts as an event). |
| 189 | + |
| 190 | +--- |
| 191 | + |
| 192 | +## Smoke test the outbound path |
| 193 | + |
| 194 | +Confirm NC can post before relying on the schedule. |
| 195 | + |
| 196 | +`POST <API host>/v1/messages` with an admin bearer token: |
| 197 | + |
| 198 | +```json |
| 199 | +{ |
| 200 | + "conversation": "<NC conversation id from Part B>", |
| 201 | + "body": "Number Cruncher online.", |
| 202 | + "bodyType": "text", |
| 203 | + "channels": [{ "name": "numberCruncher", "passcode": null }] |
| 204 | +} |
| 205 | +``` |
| 206 | + |
| 207 | +The message should appear in NC's alert channel. |
| 208 | + |
| 209 | +## Verify the budget check |
| 210 | + |
| 211 | +Wait for the next scheduled check (based on `checkInterval`), or reprovision with a short |
| 212 | +interval like `"checkInterval": "60"` to trigger a check within a minute. If any configured |
| 213 | +budget is above its threshold, NC posts a budget alert card to the channel showing a |
| 214 | +progress bar, percent used, and dollar amounts for each over-threshold budget. |
| 215 | + |
| 216 | +To force an alert regardless of actual usage, temporarily lower `thresholdPercent` to `0` |
| 217 | +in the `budgets` array and update via `PUT /v1/conversations`. |
| 218 | + |
| 219 | +--- |
| 220 | + |
| 221 | +## Gotchas, in one place |
| 222 | + |
| 223 | +- Provision (create the Adapter row) BEFORE setting the Slack Request URL, or challenge |
| 224 | + verification fails. |
| 225 | +- One Slack app = one Request URL. Use a separate app per environment. |
| 226 | +- `chat:write` is the only scope needed. NC never reads messages. |
| 227 | +- `checkInterval` is in **seconds**, not hours. `3600` = 1 hour, `86400` = 24 hours. |
| 228 | +- `POST /v1/messages` channels are `[{ "name", "passcode" }]` objects. Use `passcode: null` |
| 229 | + to bypass the channel passcode. |
| 230 | +- Secrets (bot token, signing secret, budget API keys) go in the provisioning call, stored |
| 231 | + on the Adapter/Conversation rows — not in `.env`. The global `SLACK_SIGNING_SECRET` is |
| 232 | + only a fallback. |
| 233 | +- NC must be invited to its alert channel. |
| 234 | +- Budget endpoint responses must have the shape |
| 235 | + `{ "quota": { "limit": "250.0" }, "remaining_limit": "199.40" }`. Unexpected shapes are |
| 236 | + skipped with a warning log. |
| 237 | +- Each budget's `apiKey` is sent as `Authorization: Bearer <apiKey>`. If an endpoint returns |
| 238 | + a non-2xx status, that budget is skipped and the others still run. |
0 commit comments