Token Usage Ingestion
Endpoint contract and operator workflow for importing local agent token usage into ruchern.dev.
The token usage ingestion workflow parses local coding-agent logs, prices the token usage, folds events into daily aggregates, and writes those aggregates into the token_usage table. AgentUsage may also POST session-level effort aggregates into token_effort_usage.
Parsing happens locally because agent logs live on the machine that ran the agents. Production writes happen through the deployed API route, so the production DATABASE_URL never has to be present on a local machine.
Endpoint
POST /api/usage/ingestThe endpoint is implemented in the web app and upserts rows into whichever database that deployment is configured to use.
apps/web/src/app/api/usage/ingest/route.tsAuthentication
Requests must send Authorization: Bearer <access_token>, where the token is an OAuth access token issued by this site's OAuth provider to a user whose role is admin. agent-usage auth login obtains one.
The static BLOG_MCP_AUTH_TOKEN bearer and Better Auth sessions are rejected.
Request Body
The body must match UsageIngestPayload from @workspace/usage/ingest. Field names are camelCase (matching AgentUsage Codable and the token-row wire style).
{
"rows": [
{
"date": "2026-06-02",
"agent": "claude",
"provider": "anthropic",
"model": "claude-sonnet-4-5",
"inputTokens": 1200,
"outputTokens": 420,
"cacheReadTokens": 3000,
"cacheWriteTokens": 250,
"reasoningTokens": 0,
"totalTokens": 5870,
"costUsd": "0.012345",
"messages": 3
}
],
"effortRows": [
{
"date": "2026-06-02",
"agent": "claude",
"levels": [{ "level": "high", "sessionCount": 2 }],
"classifiedSessionCount": 2,
"unclassifiedSessionCount": 1
}
],
"effortSnapshotComplete": true
}effortRows and effortSnapshotComplete are optional (default [] / false) for older clients. Token rows remain required — effort-only POSTs are rejected (400).
Row Contract
Each row represents one daily aggregate for a specific (date, agent, provider, model) tuple.
| Field | Type | Notes |
|---|---|---|
date | YYYY-MM-DD string | Local calendar date for the aggregate. |
agent | string | Coding tool that produced the logs, such as claude, codex, or opencode. |
provider | string | Inference provider that billed the usage, such as anthropic, openai, or fireworks-ai. |
model | string | Model identifier from the source log. |
inputTokens | non-negative integer | Input token count. |
outputTokens | non-negative integer | Output token count. |
cacheReadTokens | non-negative integer | Cache-read input token count. |
cacheWriteTokens | non-negative integer | Cache-write input token count. |
reasoningTokens | non-negative integer | Reasoning output token count. |
totalTokens | non-negative integer | Sum used by the usage page for token totals. |
costUsd | decimal string or null | Fixed-point USD cost. null means the model could not be priced, not a real $0. |
messages | non-negative integer | Number of source usage events folded into the row. |
The request must include at least one row and at most 20,000 rows.
Effort Row Contract
Each effort row is one daily session-level aggregate for (date, agent). Counts are sessions, not requests or tokens — each session contributes its dominant effort level (ties → mixed). Levels are open-ended strings (minimal | low | medium | high | xhigh | max | ultra | mixed | future).
| Field | Type | Notes |
|---|---|---|
date | YYYY-MM-DD string | Local calendar date for the aggregate. |
agent | string | Coding tool that produced the sessions. |
levels | array of { level, sessionCount } | Classified sessions by effort level. |
classifiedSessionCount | non-negative integer | Sessions with a known dominant effort. |
unclassifiedSessionCount | non-negative integer | Sessions without a classifiable effort. |
effortSnapshotComplete is accepted on the wire but does not delete unspecified dates — only provided keys are upserted.
Idempotency
Ingestion is idempotent. Token rows are upserted by the composite key:
date + agent + provider + modelEffort rows are upserted by:
date + agentNon-decreasing on conflict. Both tables guard against prune erosion: a day is overwritten only when the incoming snapshot is more complete — larger totalTokens for tokens, or larger classifiedSessionCount + unclassifiedSessionCount for effort. Smaller re-parses from pruned logs are ignored. The whole incoming row wins together.
Cache Revalidation
After a successful upsert, the route revalidates the usage cache tag so the public usage page can pick up the new data (tokens and effort share this tag via getUsageProfile).
Ingesting From This Machine
The agent-usage CLI (a Rust collector in apps/cli) parses local agent logs and POSTs daily rows to the ingest endpoint.
pnpm usage:login # once per server
pnpm usage:ingestSign in once per server (OAuth, admin account). Login follows AGENT_USAGE_URL, so a local dev server and production keep separate logins. Production tokens are stored in the Keychain; other servers keep theirs in a 0600 file in ~/.config/agent-usage, so rebuilding the CLI never triggers a Keychain password prompt. The installed collector is production only. Rows are sent with costUsd: null, and the route prices them after the model registry sync. The CLI emits token rows only; effort rows come from the AgentUsage app, a separate client.
Optional environment variables:
AGENT_USAGE_URLoverrides the endpoint (agent-usage ingest --url <URL>wins over it), e.g.https://blog.localhost/api/usage/ingestfor the local dev server (defaults toVERCEL_PROJECT_PRODUCTION_URL,VERCEL_URL, thenhttps://ruchern.dev)AGENT_USAGE_DRY_RUN=1prints the payload without POSTing (same asagent-usage ingest --dry-run)
agent-usage auth status shows the stored sign-in for the current server without a network call.
To install the prebuilt collector and its LaunchAgent on a Mac without a checkout:
curl -fsSL https://github.com/ruchernchong/blog/releases/latest/download/install.sh | bashagent-usage update installs the latest release in place after checking its SHA-256 (--check only reports the versions).
The CLI was renamed from usage-ingest. Each AGENT_USAGE_* variable falls back to its legacy USAGE_INGEST_* name when unset, so existing shells and .envrc files keep working.
The production database connection string stays inside the deployment environment. The local machine only sends the OAuth bearer and JSON rows.
Example Request
curl -X POST "https://ruchern.dev/api/usage/ingest" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"rows": [
{
"date": "2026-06-02",
"agent": "claude",
"provider": "anthropic",
"model": "claude-sonnet-4-5",
"inputTokens": 1200,
"outputTokens": 420,
"cacheReadTokens": 3000,
"cacheWriteTokens": 250,
"reasoningTokens": 0,
"totalTokens": 5870,
"costUsd": "0.012345",
"messages": 3
}
],
"effortRows": [
{
"date": "2026-06-02",
"agent": "claude",
"levels": [{ "level": "high", "sessionCount": 2 }],
"classifiedSessionCount": 2,
"unclassifiedSessionCount": 1
}
],
"effortSnapshotComplete": true
}'Success Response
{
"ok": true,
"upserted": 1,
"effortUpserted": 1,
"syncRunId": "run_123"
}effortUpserted is 0 when effortRows is omitted or empty.
Failure Modes
| Status | Cause |
|---|---|
401 | Missing or invalid OAuth access token, or a token not owned by an admin. |
400 | Request body does not match the ingest schema. |
500 | Database write or server-side ingestion failure. |
Source Files
packages/usage/src/ingest.tsdefines the wire schema.apps/cliparses, folds, and posts token rows.apps/web/src/app/api/usage/ingest/route.tsauthenticates and upserts rows.apps/web/src/lib/queries/usage.tsperforms the chunked upsert and usage profile aggregation.apps/web/src/schema/token-effort-usage.tsdefines thetoken_effort_usagetable.