Skip to content

Commit 17388aa

Browse files
committed
feat: add numberCruncher agent that periodically checks LLM budget endpoints and alerts near limits
checks configured endpoints for budget total and remaining, pushes alerts to slack on configurable thresholds
1 parent def52d0 commit 17388aa

9 files changed

Lines changed: 720 additions & 4 deletions

File tree

src/adapters/slack/blocks/index.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import type { KnownBlock } from '@slack/types'
2-
import { CuratedVibesData } from '../../../types/index.types.js'
2+
import { CuratedVibesData, BudgetAlertData } from '../../../types/index.types.js'
33
import renderCuratedVibesCard from './vibesAnalyst/curatedCard.js'
4+
import renderBudgetAlertCard from './numberCruncher/budgetAlertCard.js'
45

56
/* Maps an agent response's responseKind to the renderer that turns its neutral
67
renderData into Slack Block Kit. Add an entry here when an agent introduces a
78
new kind of card; individual metrics live inside each renderer, not here. */
89
const renderers: Record<string, (renderData: unknown) => KnownBlock[]> = {
9-
curatedVibesSummary: (renderData) => renderCuratedVibesCard(renderData as CuratedVibesData)
10+
curatedVibesSummary: (renderData) => renderCuratedVibesCard(renderData as CuratedVibesData),
11+
budgetAlert: (renderData) => renderBudgetAlertCard(renderData as BudgetAlertData)
1012
}
1113

1214
/**
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import type { KnownBlock } from '@slack/types'
2+
import type { BudgetAlert, BudgetAlertData } from '../../../../types/index.types.js'
3+
4+
function formatAmount(value: number): string {
5+
return value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
6+
}
7+
8+
function buildProgressBar(percent: number): string {
9+
const filled = Math.round(percent / 10)
10+
const empty = 10 - filled
11+
return '█'.repeat(filled) + '░'.repeat(empty)
12+
}
13+
14+
function buildAlertRow(alert: BudgetAlert): string {
15+
const bar = buildProgressBar(alert.percentUsed)
16+
return `*${alert.label}*\n${bar} ${Math.round(alert.percentUsed)}% used ($${formatAmount(alert.used)} / $${formatAmount(
17+
alert.limit
18+
)})`
19+
}
20+
21+
/**
22+
* Renders a budget alert into Slack Block Kit blocks. Each alert gets its own
23+
* section with a simple text progress bar showing percent used vs. the threshold.
24+
* Only budgets that exceeded the configured threshold are included — callers
25+
* filter before passing data here.
26+
*/
27+
export default function renderBudgetAlertCard(data: BudgetAlertData): KnownBlock[] {
28+
const blocks: KnownBlock[] = [
29+
{
30+
type: 'header',
31+
text: { type: 'plain_text', text: ':warning: Budget Alert', emoji: true }
32+
}
33+
]
34+
35+
for (const alert of data.alerts) {
36+
blocks.push({
37+
type: 'section',
38+
text: { type: 'mrkdwn', text: buildAlertRow(alert) }
39+
})
40+
}
41+
42+
blocks.push({ type: 'divider' })
43+
44+
const checkedAt = new Date(data.checkedAt).toLocaleString('en-US', {
45+
month: 'short',
46+
day: 'numeric',
47+
hour: '2-digit',
48+
minute: '2-digit',
49+
timeZoneName: 'short'
50+
})
51+
blocks.push({
52+
type: 'context',
53+
elements: [{ type: 'mrkdwn', text: `Checked at ${checkedAt}` }]
54+
})
55+
56+
return blocks
57+
}

src/agents/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import chatbot from './chatbot/chatbot.js'
1111
import eventHistorian from './eventHistorian/eventHistorian.js'
1212
import eventSetup from './eventSetup/eventSetup.js'
1313
import vibesAnalyst from './vibesAnalyst/index.js'
14+
import numberCruncher from './numberCruncher/agent.js'
1415

1516
// Development agents
1617
import civilityPerMessage from './development/civilityPerMessage.js'
@@ -53,7 +54,8 @@ const agentTypes = {
5354
librarian,
5455
eventHistorian,
5556
eventSetup,
56-
vibesAnalyst
57+
vibesAnalyst,
58+
numberCruncher
5759
}
5860

5961
for (const [key, agentType] of Object.entries(agentTypes)) {

src/agents/numberCruncher/SETUP.md

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
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

Comments
 (0)