Skip to content
Back to skills

Output Dev Cost Hooks

ASecurity

Subscribe to cost events (cost:http:request, llm:generation:metering) to forward LLM and API spend to your own observability system. Use when adding cost/spend logging, building a cost observability integration, or forwarding per-request cost data to an external system.

  • 440 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added September 20, 2026
ai-agentsjavascripttypescriptgojavaapibackend

Works with

  • cli
  • api

Security analysis

A100/100

Scanned October 2, 2026

npx -y skills add growthxai/output --skill output-dev-cost-hooks --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Output Dev Cost Hooks?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Output Dev Cost Hooks
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/growthxai-output-dev-cost-hooks/badge)](https://www.skillsdirectory.com/skills/growthxai-output-dev-cost-hooks)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: output-dev-cost-hooks
description: Subscribe to cost events (cost:http:request, llm:generation:metering) to forward LLM and API spend to your own observability system. Use when adding cost/spend logging, building a cost observability integration, or forwarding per-request cost data to an external system.
allowed-tools: [Read, Write, Edit, Glob]
---

# Cost Observability Hooks

## Overview

This skill documents how to subscribe to Output's cost events so every priced LLM call and every HTTP call with attached cost (via `addRequestCost`, see `output-dev-http-client-create`) can be forwarded to your own observability system — a webhook, a structured log pipeline, a metrics backend, etc. Handler errors are caught and logged by the framework; they never affect the workflow or the request that triggered them.

## When to Use This Skill

- Forwarding per-call spend to an external observability/webhook endpoint
- Logging cost events as structured fields for a log platform
- Building alerting or dashboards on top of workflow spend
- Auditing which workflows/activities are driving cost, in real time rather than after the fact

This skill is about **project-wide hook registration** for cost data already emitted by the framework. To make an HTTP client emit cost in the first place, see `output-dev-http-client-create`.

## Setup

### 1. Create a hook file

```typescript
// src/cost_hooks.ts
import { on } from '@outputai/core/hooks';
import type { HttpRequestCostEvent } from '@outputai/http';
import type { LLMGenerationMeteringEvent } from '@outputai/llm';

on<HttpRequestCostEvent>('cost:http:request', async event => {
  // handle HTTP cost
});

on<LLMGenerationMeteringEvent>('llm:generation:metering', async event => {
  // handle LLM cost
});
```

### 2. Register the file

Add the file to `outputai.hookFiles` in `package.json`, alongside any existing hook files. Paths are relative to the package root and point at the **built** `.js` output — `npm run output:worker:build` compiles `src/` to `dist/` (per `tsconfig.json`'s `rootDir`/`outDir`), so `src/cost_hooks.ts` is registered as `dist/cost_hooks.js`, not the `.ts` source itself. The worker loads these files at startup.

```json
{
  "outputai": {
    "hookFiles": [
      "./dist/cost_hooks.js"
    ]
  }
}
```

If you'd rather skip the build step, a hook file can also be plain, uncompiled JavaScript registered directly at its `src/` path (`"./src/cost_hooks.js"`) — see `https://docs.output.ai/operations/error-hooks` for that variant. The rest of this skill uses TypeScript + the built-output path, since that's what the framework's own examples (and every other file in a scaffolded project) use.

## Events You Can Subscribe To

| Event | Type import | When it fires | Prefer for |
|-------|-------------|----------------|-------------|
| `llm:generation:metering` | `LLMGenerationMeteringEvent` from `@outputai/llm` | After every LLM generation (text, image, Agent, streaming) that reports usage — including failed calls that got at least partial usage | New LLM cost integrations |
| `cost:llm:request` | `LLMUsageEvent` from `@outputai/llm` | Legacy/compatible LLM cost event, same completion path | Existing handlers only — do not use for new work |
| `cost:http:request` | `HttpRequestCostEvent` from `@outputai/http` | Only when your code (or a client's `afterResponse` hook) calls `addRequestCost(response, total)` | Non-LLM paid API calls |

Every event carries the same envelope: `eventId` (UUID v4, stable idempotency key), `eventDate` (ms epoch), `activityInfo` and `workflowDetails` (present when emitted from within a step/evaluator), `outputActivityKind`, and `payload` (the event-specific data described above).

## Pattern 1: Forward to an external endpoint

Use this when spend needs to reach an external observability system over HTTP. Forward the raw envelope plus payload; redact anything that might carry secrets (API keys or tokens embedded in query strings) before logging or sending the URL.

```typescript
// src/cost_hooks.ts
import { on } from '@outputai/core/hooks';
import { credentials } from '@outputai/core/credentials';
import type { HttpRequestCostEvent } from '@outputai/http';
import type { LLMGenerationMeteringEvent } from '@outputai/llm';

// Use plain fetch here, not createKyClient/outputFetch. Handlers run inside
// the emitting step's async context, so a traced client would add its own
// HTTP trace event to that step's trace on every forwarded cost event.
//
// Read the credential lazily with `get`, not `require`, at module scope: a
// hook file is imported at worker startup by an unguarded `await import()`
// (there's no try/catch around it), so a `require()` that throws here takes
// the whole worker down. `get`/`require` also only see workflow-scoped
// credentials from inside an activity — at startup, outside any activity,
// they resolve the global credential set only, so keep this in a global
// (not per-workflow) credential file.
const getWebhookUrl = (): string | undefined => credentials.get('observability.webhook_url') as string | undefined;

const postEvent = async (json: Record<string, unknown>): Promise<void> => {
  const webhookUrl = getWebhookUrl();
  if (!webhookUrl) {
    return;
  }

  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 5000);
  try {
    await fetch(`${webhookUrl}/events`, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify(json),
      signal: controller.signal
    });
  } finally {
    clearTimeout(timeout);
  }
};

// Strip query strings — some APIs put API keys or tokens there.
const redactUrl = (url: string): string => {
  try {
    const parsed = new URL(url);
    return `${parsed.origin}${parsed.pathname}`;
  } catch {
    return '[unparseable-url]';
  }
};

on<HttpRequestCostEvent>('cost:http:request', async event => {
  if (!event.workflowDetails || !event.payload) {
    return;
  }

  // A caught failure here logs with this handler's own context; an
  // uncaught one is still caught and logged by the framework either way.
  try {
    await postEvent({
      eventId: event.eventId,
      eventDate: event.eventDate,
      workflowId: event.workflowDetails.workflowId,
      kind: 'http',
      url: redactUrl(event.payload.url),
      totalUsd: event.payload.total
    });
  } catch (error) {
    console.warn('cost_hooks: failed to forward HTTP cost event', error);
  }
});

on<LLMGenerationMeteringEvent>('llm:generation:metering', async event => {
  if (!event.workflowDetails || !event.payload) {
    return;
  }

  try {
    await postEvent({
      eventId: event.eventId,
      eventDate: event.eventDate,
      workflowId: event.workflowDetails.workflowId,
      kind: 'llm',
      providerId: event.payload.usage.providerId,
      modelId: event.payload.usage.modelId,
      totalUsd: event.payload.cost?.total ?? null
    });
  } catch (error) {
    console.warn('cost_hooks: failed to forward LLM cost event', error);
  }
});
```

## Pattern 2: Structured logging

Use this when spend just needs to land in your log platform as structured facets, without a separate network call. Use the framework `Logger` so fields are emitted consistently with the rest of the worker's logs.

```typescript
// src/cost_hooks.ts
import { on } from '@outputai/core/hooks';
import { Logger } from '@outputai/core';
import type { HttpRequestCostEvent } from '@outputai/http';
import type { LLMGenerationMeteringEvent } from '@outputai/llm';

const log = Logger.createLogger('CostObservability');

const redactUrl = (url: string): string => {
  try {
    const parsed = new URL(url);
    return `${parsed.origin}${parsed.pathname}`;
  } catch {
    return '[unparseable-url]';
  }
};

on<HttpRequestCostEvent>('cost:http:request', async event => {
  if (!event.workflowDetails || !event.payload) {
    return;
  }

  log.info('http_request_cost', {
    eventId: event.eventId,
    workflowId: event.workflowDetails.workflowId,
    url: redactUrl(event.payload.url),
    totalUsd: event.payload.total
  });
});

on<LLMGenerationMeteringEvent>('llm:generation:metering', async event => {
  if (!event.workflowDetails || !event.payload) {
    return;
  }

  log.info('llm_generation_cost', {
    eventId: event.eventId,
    workflowId: event.workflowDetails.workflowId,
    providerId: event.payload.usage.providerId,
    modelId: event.payload.usage.modelId,
    totalUsd: event.payload.cost?.total ?? null
  });
});
```

## Handler Safety Rules

- **Handlers are wrapped in try/catch by the framework.** A thrown or rejected handler is caught and logged (e.g. `<eventName> hook error`) and never affects the workflow, the worker, or the request that triggered the event — the same guarantee `onError` handlers get. Adding your own `try/catch` is still worthwhile so a forwarding failure logs with context you control, but it's not required for safety.
- **Skip incomplete events.** Guard on `event.workflowDetails` and `event.payload` before using them — they're only populated when the event was emitted from within a step/evaluator context; `eventId` and `eventDate`, by contrast, are always present.
- **Redact before logging or sending URLs.** Some third-party APIs embed API keys or tokens in query strings. Strip the query string (and any path segments you know carry secrets) before it leaves the process.
- **Use `eventId` as an idempotency key** when forwarding to a system that might receive the same event more than once (e.g. retried delivery).
- **Expect one event per attempt.** A step or evaluator that retries emits one cost event per attempt — that's correct, since each attempt is a real billed call.
- **Prefer `llm:generation:metering` over `cost:llm:request`** for new integrations; the legacy event's shape is frozen and misses newer cost types (e.g. tool/grounding charges).

## Verification Checklist

- [ ] Hook file registered in `outputai.hookFiles` in `package.json`, pointing at the **built** `.js` path (`dist/...`), not the `.ts` source
- [ ] Hook file imports `on` from `@outputai/core/hooks`
- [ ] Handlers guard on missing `workflowDetails` / `payload`
- [ ] URLs are redacted (query string stripped) before logging or forwarding
- [ ] `llm:generation:metering` used instead of legacy `cost:llm:request` for new work

## Related Skills

- `output-dev-http-client-create` - Attaching cost to a paid API client with `addRequestCost`
- `output-dev-workflow-cost` - Post-hoc cost calculation for a single completed workflow run via the CLI
- `output-dev-credentials` - Storing the observability endpoint URL/token as a credential

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.