@katrinalaszlo/agentkey
v0.2.0
Published
Scoped, budgeted, time-bounded API keys for AI agents
Maintainers
Readme
agentkey
Scoped, budgeted, time-bounded API keys for AI agents.
Why
When I led self-serve at a usage-based data company, one of the most common requests was credit limits per API key. Users have asked for the same at Tanso. Account- and user-level limits are critical for enterprises, but they're heavy. What's the simple version for startups?
agentkey is that: cap what a key can spend, scope what it can do, set when it expires, and record which human authorized it. It adds a few columns to your existing keys table — it doesn't replace your auth.
Scoped keys control what an agent can do, not how much it can spend. agentkey does both, per key. LLM gateways cap spend; identity platforms scope keys; neither does both at the key level.
What it covers
The layers nobody else covers, per key:
| Layer | What it controls | Who covers it today | |---|---|---| | Identity | Who is this | Clerk, Auth0 | | Account billing | How much can this org spend | Stripe, Metronome | | Key scoping | What can this key do | agentkey | | Key budgeting | How much can this key spend | agentkey | | Key expiry | When does access end | agentkey | | Delegation | On whose behalf | agentkey |
Install
npm install @katrinalaszlo/agentkeyQuick Start
import { AgentKey } from '@katrinalaszlo/agentkey';
const ak = new AgentKey({ pool }); // pass your pg Pool
// Create a scoped key with a budget
const key = await ak.create({
accountId: 'acct_123',
scopes: ['usage.read', 'proxy.chat'],
budgetCents: 5000, // $50 cap
budgetPeriod: 'month',
expiresIn: '7d',
delegatedBy: 'user_456', // human who authorized this agent
name: 'sales-agent',
});
// => { key: 'ak_7f3a...', id: 42, expiresAt: '2026-05-20T...' }
// Validate on every request
const result = await ak.validate(key.key);
// => { valid: true, scopes: ['usage.read', 'proxy.chat'],
// budgetCents: 5000, budgetUsedCents: 1200,
// budgetRemainingCents: 3800, expiresAt: '...',
// delegatedBy: 'user_456', accountId: 'acct_123' }
// Track spend after an LLM call
await ak.trackUsage(key.key, { costCents: 15 });
// Check if a scope is allowed
ak.hasScope(result, 'proxy.chat'); // true
ak.hasScope(result, 'billing.write'); // falseHow It Works
agentkey adds columns to your existing API keys table and provides middleware to enforce scopes and budgets on every request.
One account, multiple keys, different capabilities:
Account: Acme Corp (Pro plan, $100/month)
|
|-- ak_sales_... scopes: [proxy.chat] budget: $40/mo
|-- ak_analytics_.. scopes: [usage.read] budget: $0 (free endpoints only)
|-- ak_agent_... scopes: [proxy.chat, usage.read] budget: $30/mo expires: 7dThe account's plan sets the ceiling. Keys subdivide it. No single key can blow the whole month's budget.
API
new AgentKey(options)
const ak = new AgentKey({
pool, // pg Pool instance
tableName: 'sdk_api_keys', // default
keyPrefix: 'ak_', // default
});ak.create(options)
Create a new scoped key.
| Option | Type | Required | Description |
|---|---|---|---|
| accountId | string/number | yes | Account this key belongs to |
| scopes | string[] | no | Allowed actions. null = unlimited |
| budgetCents | number | no | Spending cap in cents. null = unlimited |
| budgetPeriod | 'day' | 'month' | null | no | Budget reset interval |
| expiresIn | string | no | Duration: m=minutes, h=hours, d=days, mo=months (e.g. '30m', '1h', '7d', '1mo'). null = no expiry |
| delegatedBy | string | no | User ID of the human who authorized this key |
| name | string | no | Label for this key |
ak.validate(rawKey)
Validate a key and return its metadata. Returns { valid: false, reason: string } for invalid, expired, or revoked keys.
ak.trackUsage(rawKey, { costCents })
Increment budget usage. Returns { success: false, reason: 'budget_exceeded' } if the key's budget cap would be exceeded.
ak.hasScope(validationResult, scope)
Check if a validated key has a specific scope. Returns boolean.
ak.revoke(keyId, accountId?)
Soft-revoke a key (sets revoked_at timestamp). Returns true if a key was revoked, false if nothing matched. Pass accountId to only revoke a key that account owns — the built-in DELETE /sdk-keys/:id route does this so one key can't revoke another account's keys by guessing IDs.
External-subject keys (ak.ensureSubject / ak.validateBySubject / ak.trackUsageBySubject)
For agents that already carry a credential from an identity provider (e.g. a Clerk M2M token), you can anchor a budget row to that external identity instead of minting an ak_ key. Same budget/scope/expiry enforcement, keyed on the external subject.
ak.ensureSubject(subject, options?)— create-on-first-seen a budget row for an external identity. Idempotent (a second call is a no-op).optionstakes the samescopes/budgetCents/budgetPeriod/expiresIn/delegatedBy/nameascreate, plus optionalaccountId(defaults to the subject). Returns nothing — no token is issued; the external credential is the bearer.ak.validateBySubject(subject)— same asvalidate, keyed on the external subject.ak.trackUsageBySubject(subject, { costCents })— same astrackUsage, keyed on the external subject.
These power @katrinalaszlo/agentkey-clerk. Requires running ak.migrate() (adds the external_subject column).
Express Middleware
import { agentKeyMiddleware } from '@katrinalaszlo/agentkey';
// Protect routes with scope checks
app.get('/api/usage', agentKeyMiddleware(ak, { scope: 'usage.read' }), handler);
app.post('/api/proxy', agentKeyMiddleware(ak, { scope: 'proxy.chat' }), handler);
// Budget is tracked automatically when you call ak.trackUsage()Self-Serve Routes (optional)
createAgentKeyRoutes(ak, opts) mounts POST /signup, GET /sdk-keys/me, POST /sdk-keys, and DELETE /sdk-keys/:id.
import { createAgentKeyRoutes } from '@katrinalaszlo/agentkey';
app.use(createAgentKeyRoutes(ak, { signupScopes: ['proxy.chat'] }));Security model:
POST /signupis unauthenticated (an agent self-serves a key with just an email). It only grants scopes listed insignupScopes. WithsignupScopesunset it issues a scopeless key — it never passes caller-supplied scopes (or an unlimited scope) through, so no one can mint anadminkey from this endpoint. Set your ownbudget_cents/expires_incaps in front of it if you expose it publicly.POST /sdk-keysrequires a valid key and attenuates to the caller: it can only grant scopes the calling key already holds, and a child key's budget and expiry cannot exceed the calling key's.DELETE /sdk-keys/:idonly revokes keys owned by the calling key's account.
Database Migration
agentkey adds columns to your existing keys table:
ALTER TABLE sdk_api_keys
ADD COLUMN IF NOT EXISTS user_id TEXT,
ADD COLUMN IF NOT EXISTS scopes TEXT[],
ADD COLUMN IF NOT EXISTS budget_cents INTEGER,
ADD COLUMN IF NOT EXISTS budget_used_cents INTEGER DEFAULT 0,
ADD COLUMN IF NOT EXISTS budget_period TEXT,
ADD COLUMN IF NOT EXISTS budget_reset_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS delegated_by TEXT;Run ak.migrate() to apply automatically, or use the SQL above in your own migration system.
Use with Clerk
If your agents authenticate with Clerk M2M tokens, you don't need to mint a separate ak_ key. @katrinalaszlo/agentkey-clerk is a drop-in middleware that verifies the Clerk token and enforces an agentkey budget/scope/expiry on the machine behind it — Clerk says which machine is calling, agentkey says how much it can spend. The agent keeps carrying its Clerk token; the spend layer rides on top.
The subject-keyed methods that power it (ensureSubject, validateBySubject, trackUsageBySubject) are part of agentkey's API and can be used directly against any external identity, not just Clerk.
Why Not Just Use...
Clerk/Auth0: They scope identity, not budget. M2M tokens have scopes but no credit caps, no usage metering per key.
Stripe/Metronome: They scope account billing, not per-key. Can't tell which of 15 keys drove the cost.
Rate limiters: They scope throughput (requests/min), not dollars. 10 requests at $50 each stays under the rate limit while spending $500.
Custom code: This is what everyone builds. It takes weeks, it's different at every company, it has bugs.
License
MIT
