Skip to main content
Manifest’s alerting system lets you monitor usage and prevent runaway costs with two types of rules:
  • Email alerts: Get notified when thresholds are exceeded
  • Hard limits: Automatically block proxy requests when usage exceeds a threshold
Both rule types support token-based and cost-based thresholds over hourly, daily, weekly, or monthly periods.

Rule Types

Email Alerts

Send an email notification when a threshold is crossed. Requires an email provider to be configured (Mailgun, Resend, or SMTP).
// From packages/backend/src/notifications/dto/notification-rule.dto.ts:4-22
{
  agent_name: string,
  metric_type: 'tokens' | 'cost',  // What to measure
  threshold: number,               // Alert trigger value
  period: 'hour' | 'day' | 'week' | 'month',
  action: 'notify'                 // Send email
}
Example: Alert when daily cost exceeds $10.

Hard Limits

Block proxy requests (/v1/chat/completions) when usage exceeds the threshold. Requests return 403 Forbidden until the next period starts.
{
  agent_name: string,
  metric_type: 'tokens' | 'cost',
  threshold: number,
  period: 'hour' | 'day' | 'week' | 'month',
  action: 'block'                  // Block requests
}
Example: Block requests when hourly tokens exceed 1,000,000.
Hard limits require routing to be enabled. They only affect proxy requests (/v1/chat/completions). Direct LLM calls and OTLP ingestion are not blocked.

Combined Rules

You can create a rule with both actions:
{
  action: 'both'  // Email alert + block requests
}

Creating Rules

Rules are created via the UI (Limits page) or API:
POST /api/v1/notifications
Content-Type: application/json

{
  "agent_name": "my-agent",
  "metric_type": "cost",
  "threshold": 10,
  "period": "day",
  "action": "notify"
}
Response:
{
  "id": "rule-uuid",
  "agent_name": "my-agent",
  "metric_type": "cost",
  "threshold": 10,
  "period": "day",
  "action": "notify",
  "is_active": true,
  "trigger_count": 0,
  "created_at": "2024-03-04T12:00:00Z"
}

Threshold Checking

Manifest checks thresholds in two ways:

1. Cron-Based Checks (Email Alerts)

A background job runs every 5 minutes to check all email alert rules:
// From packages/backend/src/notifications/services/notification-cron.service.ts
@Cron('*/5 * * * *')  // Every 5 minutes
async checkThresholds(): Promise<number> {
  const rules = await this.ds.query(
    `SELECT * FROM notification_rules
     WHERE is_active = $1 AND (action = 'notify' OR action = 'both')`,
    [this.dialect === 'sqlite' ? 1 : true]
  );

  let triggered = 0;
  for (const rule of rules) {
    const shouldTrigger = await this.shouldTriggerNotification(rule);
    if (shouldTrigger) {
      await this.sendNotification(rule);
      triggered++;
    }
  }
  return triggered;
}

2. Request-Time Checks (Hard Limits)

Hard limits are checked on every proxy request:
// From packages/backend/src/notifications/services/limit-check.service.ts
async checkLimit(tenantId: string, agentName: string): Promise<void> {
  // Check cache first (5-minute TTL)
  const cacheKey = `${tenantId}:${agentName}`;
  const cached = this.limitCache.get(cacheKey);
  if (cached) {
    if (cached.blocked) throw new ForbiddenException('Hard limit exceeded');
    return;
  }

  // Query active hard limit rules
  const rules = await this.ds.query(
    `SELECT * FROM notification_rules
     WHERE tenant_id = $1 AND agent_name = $2
       AND is_active = $3 AND (action = 'block' OR action = 'both')`,
    [tenantId, agentName, this.dialect === 'sqlite' ? 1 : true]
  );

  // Check each rule's consumption
  for (const rule of rules) {
    const consumption = await this.getConsumption(tenantId, agentName, rule);
    if (consumption >= Number(rule.threshold)) {
      this.limitCache.set(cacheKey, { blocked: true, until: this.getPeriodEnd(rule.period) });
      throw new ForbiddenException(`Hard limit exceeded: ${rule.metric_type} threshold`);
    }
  }

  this.limitCache.set(cacheKey, { blocked: false, until: Date.now() + 300_000 });
}
The cache prevents repeated database queries for every request.

Period Calculations

Periods are computed using rolling windows:
  • Hour: Last 60 minutes from now
  • Day: Last 24 hours from now
  • Week: Last 7 days from now
  • Month: Last 30 days from now
// From packages/backend/src/notifications/services/notification-rules.service.ts
getPeriodBounds(period: string): { start: string; end: string } {
  const now = new Date();
  const end = now.toISOString();
  let start: Date;

  switch (period) {
    case 'hour':
      start = new Date(now.getTime() - 60 * 60 * 1000);
      break;
    case 'day':
      start = new Date(now.getTime() - 24 * 60 * 60 * 1000);
      break;
    case 'week':
      start = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
      break;
    case 'month':
      start = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
      break;
    default:
      start = now;
  }

  return { start: start.toISOString(), end };
}

Email Providers

Manifest supports three email providers:

Mailgun

{
  provider: 'mailgun',
  apiKey: 'mg_api_key',
  domain: 'mg.example.com',
  notificationEmail: '[email protected]'
}

Resend

{
  provider: 'resend',
  apiKey: 're_api_key',
  notificationEmail: '[email protected]'
}

SMTP (Sendgrid, Gmail, etc.)

{
  provider: 'sendgrid',
  apiKey: 'smtp_password',
  domain: 'smtp.sendgrid.net:587',  // host:port
  notificationEmail: '[email protected]'
}

Configure via API

POST /api/v1/notifications/email-provider
Content-Type: application/json

{
  "provider": "resend",
  "apiKey": "re_...",
  "notificationEmail": "[email protected]"
}
API keys are encrypted with AES-256-GCM before storage:
// From packages/backend/src/notifications/services/email-provider-config.service.ts
const encrypted = encrypt(body.apiKey, getEncryptionSecret());
await this.ds.query(
  `INSERT INTO email_provider_config (id, user_id, provider, api_key_encrypted, domain, notification_email, created_at)
   VALUES ($1, $2, $3, $4, $5, $6, $7)`,
  [uuid(), userId, body.provider, encrypted, body.domain, body.notificationEmail, now]
);

Email Templates

Notification emails include:
  • Subject: Manifest Alert: {metric_type} threshold exceeded
  • Body:
    • Agent name
    • Metric type (tokens or cost)
    • Threshold value
    • Current consumption
    • Period (hour, day, week, month)
    • Link to dashboard
// From packages/backend/src/notifications/services/notification-email.service.ts
const subject = `Manifest Alert: ${rule.metric_type} threshold exceeded`;
const body = `
Agent: ${rule.agent_name}
Metric: ${rule.metric_type}
Threshold: ${formatThreshold(rule)}
Current: ${formatConsumption(consumption, rule.metric_type)}
Period: ${rule.period}

View details: ${dashboardUrl}/agents/${encodeURIComponent(rule.agent_name)}/overview
`;

Notification Logs

Every triggered alert is logged to notification_logs:
{
  id: string,
  rule_id: string,
  triggered_at: string,
  consumption_value: number,
  threshold_value: number,
  period_start: string,
  period_end: string
}
The UI shows trigger_count — the total number of times a rule has fired.

UI Components

The Limits page (/agents/{agentName}/limits) displays:

Rules Table

// From packages/frontend/src/pages/Limits.tsx:266-338
<table class="notif-table">
  <thead>
    <tr>
      <th>Type</th>
      <th>Threshold</th>
      <th>Triggered</th>
      <th>Actions</th>
    </tr>
  </thead>
  <tbody>
    <For each={rules()}>
      {(rule) => (
        <tr>
          <td>
            {/* Email alert icon */}
            {hasEmailAction(rule.action) && <AlertIcon />}
            {/* Hard limit icon */}
            {hasBlockAction(rule.action) && <LimitIcon />}
          </td>
          <td>{formatThreshold(rule)} {rule.period}</td>
          <td>{rule.trigger_count}</td>
          <td><button onClick={() => handleEdit(rule)}>Edit</button></td>
        </tr>
      )}
    </For>
  </tbody>
</table>

Warning Banner

When a hard limit is active and triggered:
// From packages/frontend/src/pages/Limits.tsx:182-203
<Show when={blockRulesExceeded()}>
  <div class="limits-warning-banner">
    <span>
      One or more hard limits have been triggerednew proxy requests for this agent
      will be blocked until the usage resets in the next period.
    </span>
  </div>
</Show>

Email Provider Setup

In local mode, the Limits page shows an email provider setup card:
// From packages/frontend/src/pages/Limits.tsx:237-250
<Show when={isLocalMode()}>
  <Show
    when={emailProvider()}
    fallback={<EmailProviderSetup onConfigured={refetchProvider} />}
  >
    <ProviderBanner
      config={emailProvider()!}
      onEdit={() => setShowEditProvider(true)}
      onRemove={() => setShowRemoveProvider(true)}
    />
  </Show>
</Show>
In cloud mode, alerts are sent to the user’s account email (from Better Auth session).

API Endpoints

List Rules

GET /api/v1/notifications?agent_name=my-agent

Create Rule

POST /api/v1/notifications

Update Rule

PATCH /api/v1/notifications/{ruleId}

Delete Rule

DELETE /api/v1/notifications/{ruleId}

Test Email Provider

POST /api/v1/notifications/email-provider/test
Content-Type: application/json

{
  "provider": "resend",
  "apiKey": "re_...",
  "to": "[email protected]"
}
Hard limits are enforced only for proxy requests (/v1/chat/completions). They do not affect OTLP ingestion or direct API calls.

Cache Invalidation

When a rule is created, updated, or deleted, the limit check cache is invalidated:
// From packages/backend/src/notifications/notifications.controller.ts:96-101
@Post()
async createRule(@Body() dto: CreateNotificationRuleDto, @CurrentUser() user: AuthUser) {
  const rule = await this.rulesService.createRule(user.id, dto);
  if (rule.action === 'block' || rule.action === 'both') {
    this.limitCheck.invalidateCache(rule.tenant_id, rule.agent_name);
  }
  return rule;
}
This ensures the next proxy request re-checks the database for updated rules.

Build docs developers (and LLMs) love