Skip to content

Usage Tracking ​

Track LLM token consumption, tool executions, sub-agent calls, and custom metrics across your agent runs. Usage tracking provides visibility into costs, performance, and resource utilization.

Compute cost from the per-kind fields, never from total

A model call cut short (a Stop or interrupt) books only what the provider reported before the cut: its input and cache tokens (prompt, cached, cacheWrite). Its output count was never reported, so its completion and total are absent. A session's tokens.total therefore sums only the calls that reported a total, and after an interrupted call it is less than prompt + completion. Bill from prompt, completion, cached and cacheWrite.

Nothing marks such usage as partial. A stopped call's result is its partial text, and its usage holds the input side only, so read an absent completion as "output not reported", never as zero output. See Calls cut short.

Quick Start ​

Pass a UsageStore when executing an agent to enable tracking:

typescript
import { JSAgentExecutor, defineAgent } from '@helix-agents/sdk';
import { InMemoryUsageStore } from '@helix-agents/store-memory';

// Create stores
const stateStore = new InMemoryStateStore();
const streamManager = new InMemoryStreamManager();
const usageStore = new InMemoryUsageStore();

// Create executor
const executor = new JSAgentExecutor(stateStore, streamManager, llmAdapter);

// Execute with usage tracking
const handle = await executor.execute(agent, 'Analyze this data', { usageStore });

// Wait for completion
await handle.result();

// Get aggregated usage
const rollup = await handle.getUsageRollup();
console.log(`Total tokens: ${rollup.tokens.total}`);
console.log(`Tool calls: ${rollup.toolStats.totalCalls}`);

Core Concepts ​

Usage Entries ​

Usage tracking records four types of entries:

KindDescriptionWhen Recorded
tokensLLM token consumptionAfter each LLM call
toolTool execution statsAfter each tool completes
subagentSub-agent invocationAfter sub-agent finishes
customUser-defined metricsVia context.recordUsage()

Each entry includes:

  • id - Unique entry identifier
  • sessionId - Session this entry belongs to
  • timestamp - When the entry was recorded (epoch ms)
  • stepCount - Which step generated this entry
  • source - What generated this entry (agent, tool, or subagent)

Token Usage Entry ​

Recorded after each LLM call:

typescript
interface TokenUsageEntry {
  kind: 'tokens';
  model: string; // e.g., 'gpt-6-luna', 'claude-sonnet-5-5'
  tokens: {
    prompt?: number; // Input tokens
    completion?: number; // Output tokens
    reasoning?: number; // Reasoning tokens (o1, etc.)
    cached?: number; // Tokens served from cache (cache hits)
    cacheWrite?: number; // Tokens written to cache (new cache entries)
    total?: number; // Total tokens
  };
  // ... base fields
}

An absent field means "not reported", never 0.

Calls cut short (Stop or interrupt) ​

A model call that is stopped mid-stream, or fails after the provider started answering, still records the usage the provider reported before the end. It is a lower bound:

  • Recorded: the input side. prompt is the total input (uncached + read from cache + written to cache), with cached and cacheWrite when the provider reported them.
  • Absent: completion and total. The provider reports the output count only at the end of the stream, so the output generated before the cut is never known. A provisional output count sent at the start is not used.
  • Where it shows: the cut call's result usage (its partial text, or an error result), the afterLLMCall hook's usage, and the usage store's tokens entry for that call. No step_end usage is written for it. This holds on runtime-js, the Cloudflare Durable Object and CF Workflows. On Temporal and DBOS a stop does not yet cut the in-flight call short (CP-16), so the call finishes and reports its full usage.
  • No marker: nothing says the usage is partial. A stopped call's partial text result looks like a short completed one, except that completionTokens is absent; read that as "output not reported".

Which calls report it:

ModelA call cut short records
@helix-agents/llm-vercel with an @ai-sdk/anthropic model objectits input and cache tokens
The same, through an Anthropic-format proxy (for example LiteLLM's /v1/messages)its input and cache tokens
An OpenAI-compatible route (/chat/completions): usage arrives only in the last chunknothing (no usage on the wire)
A gateway model id string, or a V2 model objectnothing

A connection that drops before any output is retried inside the call; a usage report from that dropped attempt is not recorded.

Upstream: the AI SDK (ai 6.x, which Helix uses) reports usage only at a stream's finish, and onAbort carries only the finished steps. The gap is tracked upstream as a feature request, vercel/ai#7628, with draft PRs adding provisional usage to the stream (vercel/ai#21792; v6 backport vercel/ai#21793). Helix reads the provider's raw message_start event in the meantime. From @ai-sdk/anthropic 4 (with ai 7), the provider reports the same usage as a stream part of its own, which Helix will read instead when it moves to ai 7.

Tool Usage Entry ​

Recorded after each tool execution:

typescript
interface ToolUsageEntry {
  kind: 'tool';
  toolName: string; // Tool that was executed
  durationMs: number; // Execution time in milliseconds
  success: boolean; // Whether execution succeeded
  error?: string; // Error message if failed
  // ... base fields
}

Sub-Agent Usage Entry ​

Recorded when a sub-agent completes:

typescript
interface SubAgentUsageEntry {
  kind: 'subagent';
  subAgentType: string; // Sub-agent name
  subSessionId: string; // Sub-agent's session ID (for state lookup)
  durationMs: number; // Total sub-agent execution time
  success: boolean; // Whether sub-agent completed successfully
  error?: string; // Error message if failed
  // ... base fields
}

Custom Usage Entry ​

Recorded via context.recordUsage() in tools:

typescript
interface CustomUsageEntry {
  kind: 'custom';
  type: string; // Metric category (e.g., 'api_calls')
  name: string; // Metric instance (e.g., 'tavily')
  value: number; // Numeric value
  // ... base fields
}

UsageStore Interface ​

All usage stores implement this interface:

typescript
interface UsageStore {
  // Record a single entry
  recordEntry(entry: UsageEntryInput, context?: UsageRecordContext): Promise<void>;

  // Retrieve entries for a session (with optional filtering)
  getEntries(sessionId: string, filter?: EntryFilter): Promise<UsageEntry[]>;

  // Get aggregated usage rollup
  getRollup(sessionId: string, options?: RollupOptions): Promise<UsageRollup | null>;

  // Query usage entries across sessions
  queryUsage(options: QueryUsageOptions): Promise<PaginatedUsageEntries>;
}

Note: Individual store implementations may provide additional utility methods like exists(), delete(), and clear(), but these are not part of the core UsageStore interface.

UsageRollup Structure ​

The rollup aggregates all entries into a summary:

typescript
interface TokenCounts {
  prompt?: number;
  completion?: number;
  reasoning?: number;
  cached?: number; // Tokens served from cache (cache hits)
  cacheWrite?: number; // Tokens written to cache (new cache entries)
  total?: number;
}

interface UsageRollup {
  sessionId: string;

  // Token totals for THIS agent only
  tokens: TokenCounts;
  tokensByModel: Record<string, TokenCounts>;

  // Tokens including sub-agents (computed lazily)
  tokensIncludingSubAgents: TokenCounts;

  // Tool execution statistics
  toolStats: {
    totalCalls: number;
    successfulCalls: number;
    failedCalls: number;
    totalDurationMs: number;
    byTool: Record<
      string,
      {
        calls: number;
        successfulCalls: number;
        failedCalls: number;
        totalDurationMs: number;
      }
    >;
  };

  // Sub-agent invocation statistics
  subAgentStats: {
    totalCalls: number;
    successfulCalls: number;
    failedCalls: number;
    totalDurationMs: number;
    byType: Record<
      string,
      {
        calls: number;
        successfulCalls: number;
        failedCalls: number;
        totalDurationMs: number;
      }
    >;
  };

  // Custom metrics aggregated by type -> name -> value
  custom: Record<string, Record<string, number>>;

  // Timing
  startedAt?: number;
  lastUpdatedAt?: number;
  entryCount: number;
}

Lazy Sub-Agent Aggregation ​

Sub-agent tokens are not copied into the parent at recording time. Instead:

  1. Parent records a subagent entry with the child's subSessionId
  2. When you call getUsageRollup(), the store:
    • Looks up each sub-agent's subSessionId from the entries
    • Fetches the sub-agent's rollup recursively
    • Aggregates tokens and custom metrics into tokensIncludingSubAgents

This lazy approach avoids data duplication and handles deep nesting correctly.

typescript
// Get parent's own tokens only (no sub-agent aggregation)
const parentOnly = await handle.getUsage();
console.log(parentOnly.tokens.total); // Parent's tokens only

// Get combined tokens (parent + all sub-agents recursively)
const combined = await handle.getUsageRollup();
console.log(combined.tokensIncludingSubAgents.total); // All tokens including sub-agents

Persistent companions are tracked the same way. A persistentAgents child (a companion managed via companion__* tools) records its token, tool, and custom usage under its own child session (${parentSessionId}-agent-${name}), exactly like an ephemeral createSubAgentTool child. The parent records a subagent entry for the companion, so getUsageRollup({ includeSubAgents: true }) aggregates the companion's cost into the parent — including custom metrics recorded via context.recordUsage() inside the companion's tools. A companion's usage accumulates across re-consults (continuations) on its preserved child session and is deduplicated by subSessionId, so it is counted exactly once.

The parent's subagent entry for a companion is a discovery pointer: recorded exactly once per companion child session across all runtimes, by the first spawn entry that starts that session (a respawn, when an earlier first spawn of the name never started), with provisional durationMs: 0 / success: true. Because a companion is long-lived and re-consultable, a single parent-side duration/success is ill-defined — the companion's accurate outcome and duration live on its child-session rollup + state, which you should read directly rather than the parent's subAgentStats. (Cost/token aggregation is always accurate; it reads the child's live rollup.)

Exception: the Cloudflare Durable Object runtime stores usage per-DO, so cross-DO aggregation is unsupported for both companions and ephemeral sub-agents; walk sub-session refs to reconcile cost there, or configure a shared usage store.

Recording Custom Metrics ​

Tools can record custom usage metrics via context.recordUsage(). This is useful for tracking API calls, bytes processed, credits consumed, or any domain-specific metric.

Basic Usage ​

typescript
const searchTool = defineTool({
  name: 'tavily_search',
  inputSchema: z.object({ query: z.string() }),
  execute: async (input, context) => {
    const results = await tavilySearch(input.query);

    // Record custom metrics
    await context.recordUsage?.({ type: 'api_calls', name: 'tavily' }, 1);
    await context.recordUsage?.({ type: 'credits', name: 'search' }, 0.01);

    return results;
  },
});

The metrics are stored as custom entries and aggregated in the rollup:

typescript
const rollup = await handle.getUsageRollup();
console.log(rollup.custom['api_calls']['tavily']); // 1
console.log(rollup.custom['credits']['search']); // 0.01

Type-Safe Metrics with defineMetric() ​

For better type safety and documentation, define metric types:

typescript
import { defineMetric } from '@helix-agents/core';

// Define typed metrics
const API_CALLS = defineMetric('api_calls', 'External API call count');
const BYTES_PROCESSED = defineMetric('bytes.processed', 'Bytes processed');
const CREDITS_USED = defineMetric('credits', 'Service credits consumed');

// Use in tools
const tool = defineTool({
  name: 'process_file',
  execute: async (input, context) => {
    const data = await fetchFile(input.url);

    // Type-safe recording
    await context.recordUsage?.(API_CALLS, 'cdn', 1);
    await context.recordUsage?.(BYTES_PROCESSED, 'download', data.length);

    return processData(data);
  },
});

Framework Built-in Metrics ​

The framework provides constants for common token metrics:

typescript
import {
  METRIC_TOKENS_INPUT, // 'tokens.input'
  METRIC_TOKENS_OUTPUT, // 'tokens.output'
  METRIC_TOKENS_REASONING, // 'tokens.reasoning'
  METRIC_TOKENS_CACHED, // 'tokens.cached'
  METRIC_TOKENS_CACHE_WRITE, // 'tokens.cacheWrite'
} from '@helix-agents/core';

These are used internally by the framework but you can reference them in custom logic.

Metric Naming Conventions ​

Use a consistent naming scheme:

typescript
// Category-based (recommended)
{ type: 'api_calls', name: 'tavily' }
{ type: 'api_calls', name: 'openai' }
{ type: 'bytes', name: 'input' }
{ type: 'bytes', name: 'output' }

// Hierarchical types
{ type: 'llm.embeddings', name: 'openai' }
{ type: 'llm.completions', name: 'anthropic' }
{ type: 'storage.read', name: 's3' }
{ type: 'storage.write', name: 's3' }

The rollup aggregates by type first, then by name:

typescript
rollup.custom = {
  api_calls: { tavily: 5, openai: 3 },
  bytes: { input: 10240, output: 2048 },
};

Retrieving Usage Data ​

Getting Raw Entries ​

Use handle.getUsage() to get raw entries:

typescript
const entries = await handle.getUsage();
for (const entry of entries) {
  switch (entry.kind) {
    case 'tokens':
      console.log(`LLM call: ${entry.model}, ${entry.tokens.total} tokens`);
      break;
    case 'tool':
      console.log(`Tool: ${entry.toolName}, ${entry.durationMs}ms`);
      break;
    case 'custom':
      console.log(`Custom: ${entry.type}/${entry.name} = ${entry.value}`);
      break;
  }
}

Filtering Entries ​

Filter entries by kind, step range, or time range:

typescript
// Only token entries
const tokens = await usageStore.getEntries(sessionId, {
  kinds: ['tokens'],
});

// Entries from steps 5-10
const midRun = await usageStore.getEntries(sessionId, {
  stepRange: { min: 5, max: 10 },
});

// Entries from the last hour
const recent = await usageStore.getEntries(sessionId, {
  timeRange: {
    start: Date.now() - 3600000,
    end: Date.now(),
  },
});

// Pagination
const page = await usageStore.getEntries(sessionId, {
  limit: 10,
  offset: 20,
});

Getting Aggregated Rollup ​

Use handle.getUsageRollup() for aggregated stats:

typescript
const rollup = await handle.getUsageRollup();

// Token summary
console.log('Token usage:');
console.log(`  Prompt: ${rollup.tokens.prompt}`);
console.log(`  Completion: ${rollup.tokens.completion}`);
console.log(`  Total: ${rollup.tokens.total}`);

// By model
for (const [model, counts] of Object.entries(rollup.tokensByModel)) {
  console.log(`  ${model}: ${counts.total} tokens`);
}

// Tool stats
console.log('Tool usage:');
console.log(`  Total calls: ${rollup.toolStats.totalCalls}`);
console.log(`  Success rate: ${rollup.toolStats.successfulCalls / rollup.toolStats.totalCalls}`);
for (const [tool, stats] of Object.entries(rollup.toolStats.byTool)) {
  console.log(`  ${tool}: ${stats.calls} calls, ${stats.totalDurationMs}ms total`);
}

// Custom metrics
console.log('Custom metrics:');
for (const [type, names] of Object.entries(rollup.custom)) {
  for (const [name, value] of Object.entries(names)) {
    console.log(`  ${type}/${name}: ${value}`);
  }
}

Including Sub-Agent Usage ​

The handle provides two methods for retrieving usage:

typescript
// getUsage() - Parent only (no sub-agent aggregation)
const parentRollup = await handle.getUsage();

// getUsageRollup() - Parent + all sub-agents (recursive)
const totalRollup = await handle.getUsageRollup();

console.log(`Parent tokens: ${parentRollup?.tokens.total}`);
console.log(`Total tokens: ${totalRollup?.tokensIncludingSubAgents.total}`);

Store Implementations ​

InMemoryUsageStore ​

For development and testing. Data is lost when process exits.

typescript
import { InMemoryUsageStore } from '@helix-agents/store-memory';

const usageStore = new InMemoryUsageStore();

// Testing utilities
usageStore.clear(); // Clear all data
usageStore.getAllSessionIds(); // List all tracked sessions
usageStore.size; // Number of sessions
usageStore.totalEntryCount; // Total entries across all sessions

RedisUsageStore ​

For production. Persists to Redis with configurable TTL.

typescript
import Redis from 'ioredis';
import { RedisUsageStore } from '@helix-agents/store-redis';

const redis = new Redis(process.env.REDIS_URL);
const usageStore = new RedisUsageStore(redis, {
  keyPrefix: 'myapp', // Key prefix (default: 'helix')
  ttlSeconds: 86400 * 7, // Retention period (default: 24h)
});

// Find tracked sessions
const sessionIds = await usageStore.findSessionIds();

// Get entry count without fetching entries
const count = await usageStore.getEntryCount(sessionId);

D1UsageStore ​

For Cloudflare Workers. Persists to D1 SQLite database.

typescript
import { D1UsageStore } from '@helix-agents/store-cloudflare';

const usageStore = new D1UsageStore({
  database: env.DB,
  tableName: 'usage_entries', // Optional, default: 'usage_entries'
});

// Cleanup old entries
await usageStore.deleteOldEntries(7 * 24 * 60 * 60 * 1000); // Older than 7 days

// Find sessions with filters
const sessionIds = await usageStore.findSessionIds({
  agentType: 'researcher',
  limit: 100,
});

Runtime Integration ​

Usage tracking works identically across all runtimes. Just pass the usageStore option.

Usage writes are best-effort. A framework usage write (an LLM call's tokens, a tool's or sub-agent's entry, a client call's wait metric and outcome counters) that throws never fails the run and is never retried: the entry is dropped. On Cloudflare Workflows and Temporal this also means a failed token write never retries the LLM step, which would call the model again. Writes made through core's recordUsageBestEffort log usage.write.failed at warn ({ sessionId, write, error }, error with its cause chain); the usage hooks log their own usage.hook.*_failed events. A tool's own ctx.recordUsage still throws to the tool.

JS Runtime ​

typescript
import { JSAgentExecutor } from '@helix-agents/runtime-js';
import { InMemoryUsageStore } from '@helix-agents/store-memory';

const executor = new JSAgentExecutor(stateStore, streamManager, llmAdapter);
const usageStore = new InMemoryUsageStore();

const handle = await executor.execute(agent, 'Do the task', { usageStore });

Temporal Runtime ​

typescript
import { TemporalAgentExecutor } from '@helix-agents/runtime-temporal';
import { RedisUsageStore } from '@helix-agents/store-redis';

const executor = new TemporalAgentExecutor(client, stateStore, streamManager, llmAdapter);
const usageStore = new RedisUsageStore(redis);

const handle = await executor.execute(agent, 'Do the task', { usageStore });

Cloudflare Workflows Runtime ​

The CFW Workflows runtime records usage in the workflow body, which runs in a separate execution context from the executor. So unlike the JS runtime, passing a usageStore to the executor is not enough to record usage — the executor's usageStore only powers the read side (handle.getUsageRollup() / executor.getUsageRollup()). To record usage you must also thread a usageStore into the WorkflowDependencies your WorkflowEntrypoint builds when it calls runAgentWorkflow — use a shared/aggregating store (a D1UsageStore over the same D1 binding) so getUsageRollup({ includeSubAgents: true }) can surface sub-agent / companion cost on the parent:

typescript
import { runAgentWorkflow } from '@helix-agents/runtime-cloudflare';
import { D1StateStore, D1UsageStore } from '@helix-agents/store-cloudflare';
import { WorkflowEntrypoint } from 'cloudflare:workers';

export class AgentWorkflow extends WorkflowEntrypoint<Env> {
  async run(event, step) {
    return runAgentWorkflow(event, step, {
      stateStore: new D1StateStore({ database: this.env.AGENT_DB }),
      // ...streamManager, llmAdapter, registry, workflowBinding...
      // RECORDING: thread the usage store into the workflow body.
      usageStore: new D1UsageStore({ database: this.env.AGENT_DB }),
    });
  }
}

// READING: configure the SAME store kind on the executor so getUsageRollup works.
const executor = new CloudflareAgentExecutor({
  /* ...bindings..., */ usageStore: (env) => new D1UsageStore({ database: env.AGENT_DB }),
});
const rollup = await executor.getUsageRollup(sessionId, { includeSubAgents: true });

Without WorkflowDependencies.usageStore, the CFW Workflows runtime records no usage and rollups are empty.

Cloudflare Durable Objects Runtime ​

For the Durable Objects runtime, usage tracking is built-in via DOUsageStore. The AgentServer automatically creates and uses a DOUsageStore when executing agents:

typescript
// Usage is tracked automatically inside the DO - no need to pass usageStore.
// Read it from the session's DO with its /usage route (mode: self | rollup | entries):
const stub = env.AGENTS.get(env.AGENTS.idFromName(sessionId));
const response = await stub.fetch('https://internal/usage?mode=rollup', {
  headers: { 'x-partykit-room': sessionId }, // partyserver names the DO from this header
});
const rollup = (await response.json()) as UsageRollup | null;
console.log(`Total tokens: ${rollup?.tokens.total}`);

Automatic Usage Tracking

The DO runtime creates DOUsageStore automatically using the DO's built-in SQLite storage. Inside the DO it is the usage store the run writes; from the Worker, read it with the DO's /usage route as above.

Sub-Agent Usage Limitation

In the DO runtime, sub-agent usage aggregation requires cross-DO communication, which is not yet implemented. getUsageRollup() will return the parent agent's usage only, with a warning logged for any sub-agent entries.

Reconnecting to Existing Runs ​

Get usage from a previous session using getHandle():

typescript
// Later, reconnect to get usage
const handle = await executor.getHandle(agent, sessionId, { usageStore });
const rollup = await handle?.getUsageRollup();

Patterns ​

Cost Tracking ​

Calculate costs from token usage:

typescript
const PRICING = {
  'gpt-6.1-sol': { input: 0.002, output: 0.01 },
  'gpt-6-luna': { input: 0.0001, output: 0.0005 },
  'claude-sonnet-5-5': { input: 0.015, output: 0.075 },
};

async function calculateCost(handle: AgentExecutionHandle): Promise<number> {
  const rollup = await handle.getUsageRollup({ includeSubAgents: true });
  if (!rollup) return 0;

  let totalCost = 0;

  for (const [model, tokens] of Object.entries(rollup.tokensByModel)) {
    const pricing = PRICING[model];
    if (pricing) {
      totalCost += ((tokens.prompt ?? 0) / 1000) * pricing.input;
      totalCost += ((tokens.completion ?? 0) / 1000) * pricing.output;
      // Cached tokens are typically billed at a reduced rate (e.g., 90% discount for Anthropic)
      // Adjust based on your provider's pricing
    }
  }

  return totalCost;
}

Token Budgets ​

Enforce token limits with hooks:

typescript
import { defineAgent } from '@helix-agents/sdk';

const MAX_TOKENS = 100000;

const agent = defineAgent({
  name: 'budget-agent',
  hooks: {
    afterLLMCall: async ({ usage, state }) => {
      if (!usage) return;

      // Track cumulative usage in state. Sum the per-kind fields: a call cut
      // short reports its input only, with no `totalTokens`.
      const current = state.customState?.totalTokens ?? 0;
      const newTotal = current + (usage.promptTokens ?? 0) + (usage.completionTokens ?? 0);

      if (newTotal > MAX_TOKENS) {
        throw new Error(`Token budget exceeded: ${newTotal}/${MAX_TOKENS}`);
      }

      // Store for next check (via state update in tool)
    },
  },
  // ...
});

Analytics Integration ​

Export usage to analytics systems:

typescript
async function exportToAnalytics(sessionId: string, usageStore: UsageStore) {
  const rollup = await usageStore.getRollup(sessionId, { includeSubAgents: true });
  if (!rollup) return;

  // Send to your analytics service
  await analytics.track('agent_run_completed', {
    sessionId,
    totalTokens: rollup.tokensIncludingSubAgents.total,
    tokenCost: calculateCost(rollup),
    toolCalls: rollup.toolStats.totalCalls,
    toolSuccessRate: rollup.toolStats.successfulCalls / rollup.toolStats.totalCalls,
    subAgentCalls: rollup.subAgentStats.totalCalls,
    customMetrics: rollup.custom,
    duration: (rollup.lastUpdatedAt ?? 0) - (rollup.startedAt ?? 0),
  });
}

Per-Tool Cost Tracking ​

Track costs at the tool level with custom metrics:

typescript
const openAITool = defineTool({
  name: 'openai_embed',
  execute: async (input, context) => {
    const result = await openai.embeddings.create({
      model: 'text-embedding-3-small',
      input: input.text,
    });

    // Record embedding tokens as custom metric
    const tokens = result.usage.total_tokens;
    await context.recordUsage?.({ type: 'embedding_tokens', name: 'openai' }, tokens);

    // Record estimated cost
    const cost = (tokens * 0.00002) / 1000;
    await context.recordUsage?.({ type: 'cost', name: 'embeddings' }, cost);

    return result.data[0].embedding;
  },
});

Multi-Tenant Usage Isolation ​

Use separate stores or key prefixes per tenant:

typescript
// Option 1: Separate stores per tenant
const usageStores = new Map<string, UsageStore>();

function getUsageStore(tenantId: string): UsageStore {
  if (!usageStores.has(tenantId)) {
    usageStores.set(
      tenantId,
      new RedisUsageStore(redis, {
        keyPrefix: `tenant:${tenantId}`,
      })
    );
  }
  return usageStores.get(tenantId)!;
}

// Option 2: Include tenant in sessionId
const sessionId = `${tenantId}:${uuid()}`;

Utility Functions ​

The @helix-agents/core package exports helper functions:

typescript
import {
  createEmptyRollup,
  addTokenCounts,
  hasTokenCounts,
  isTokenUsageEntry,
  isToolUsageEntry,
  isSubAgentUsageEntry,
  isCustomUsageEntry,
} from '@helix-agents/core';

// Create empty rollup structure
const rollup = createEmptyRollup('session-123');

// Add token counts together
const combined = addTokenCounts({ prompt: 100, completion: 50 }, { prompt: 200, completion: 100 });
// { prompt: 300, completion: 150 }

// Check if tokens were recorded
if (hasTokenCounts(rollup.tokens)) {
  console.log('Has token data');
}

// Type guards for entry types
for (const entry of entries) {
  if (isTokenUsageEntry(entry)) {
    console.log(entry.model); // TypeScript knows it's TokenUsageEntry
  }
}

Cross-Session Usage Queries ​

The queryUsage() method enables cross-session usage queries for cost tracking, analytics, and monitoring. Query across all sessions or filter by user, agent type, date range, and entry kind.

typescript
import { RedisUsageStore } from '@helix-agents/store-redis';
import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL);
const usageStore = new RedisUsageStore(redis);

// Get all token usage for a specific user
const userTokens = await usageStore.queryUsage({
  userId: 'user-123',
  kinds: ['tokens'],
});

console.log(`Found ${userTokens.total} token entries`);

// Calculate total tokens
let totalTokens = 0;
for (const entry of userTokens.entries) {
  if (entry.kind === 'tokens') {
    totalTokens += entry.tokens.total ?? 0;
  }
}

// Get usage in a date range
const lastWeek = await usageStore.queryUsage({
  dateRange: {
    from: Date.now() - 7 * 24 * 60 * 60 * 1000,
    to: Date.now(),
  },
  orderBy: { field: 'timestamp', direction: 'desc' },
});

// Paginate through results
const page1 = await usageStore.queryUsage({ limit: 50, offset: 0 });
const page2 = await usageStore.queryUsage({ limit: 50, offset: 50 });

See Querying Guide for complete documentation including filtering options, pagination, and cost tracking examples.

Next Steps ​

  • Defining Tools - Learn about tool context and recording metrics
  • Sub-Agents - Understand sub-agent usage aggregation
  • Hooks - Use hooks for usage-based control flow
  • State Management - Combine usage tracking with state
  • Querying - Cross-session queries for analytics and monitoring

Released under the MIT License.