Inngest AgentKit (TypeScript) — wrapWithSpendGuard(step.ai)
Inngest AgentKit wraps every LLM call as a durable step via
step.ai.infer()/step.ai.wrap(). Inngest’s deterministic-retry contract gives us the cleanest pre-call hook SpendGuard has in TypeScript: on failure, the SAME step body runs again with the SAMEstep.idand (when supplied) the SAMEstep.idempotencyKey. SpendGuard ships aswrapWithSpendGuard(step.ai, client, opts)— a stock factory you drop onto any Inngest function’sstep.aiand hand back to the function body. No wrapper class required. The SpendGuardidempotencyKeyis derived deterministically from Inngest’s own step identity so N retry attempts collapse to ONE logical decision in the SpendGuard audit chain.
Install
Section titled “Install”pnpm add @spendguard/sdk @spendguard/inngest-agent-kit @inngest/agent-kit inngest@spendguard/sdk and @inngest/agent-kit are declared as peer
dependencies so the adapter pins neither — your project’s lockfile wins.
Node 20.10+ is required (the substrate uses stable AsyncLocalStorage).
Quick start
Section titled “Quick start”Minimal end-to-end wire — connect a SpendGuardClient to the sidecar UDS,
wrap the function’s step.ai, and drive sgStep.infer(...) inside the
function body:
import { Inngest } from "inngest";import { openai } from "@inngest/agent-kit/models";import { SpendGuardClient, InMemoryIdempotencyCache } from "@spendguard/sdk";import { wrapWithSpendGuard } from "@spendguard/inngest-agent-kit";
const client = new SpendGuardClient({ socketPath: "/var/run/spendguard/adapter.sock", tenantId: "00000000-0000-4000-8000-000000000001", runtimeKind: "inngest-agent-kit",});await client.connect();await client.handshake();
const cache = new InMemoryIdempotencyCache();const inngest = new Inngest({ id: "my-app" });
export const agentFn = inngest.createFunction( { id: "agent-fn", retries: 2 }, { event: "agent/run" }, async ({ step }) => { const sgStep = wrapWithSpendGuard(step.ai, client, { tenantId: "00000000-0000-4000-8000-000000000001", budgetId: "44444444-4444-4444-8444-444444444444", idempotencyCache: cache, claimEstimator: ({ model, body }) => [{ scopeId: "44444444-4444-4444-8444-444444444444", amountAtomic: "1000000", unit: { unit: "USD_MICROS", denomination: 1 }, }], }); return await sgStep.infer("call-openai", { model: openai({ model: "gpt-4o-mini" }), body: { messages: [{ role: "user", content: "hi" }] }, }); },);What happens on each sgStep.infer(...) call:
- The wrap derives a SpendGuard identity tuple
(decisionId, idempotencyKey, llmCallId, stepId)from Inngest’s runtime context.decisionIdandidempotencyKeyare seeded fromstep.idempotencyKey ?? step.id— both attempt-invariant by Inngest’s own contract. - If an
idempotencyCacheis supplied, the wrap probes it first. On cache HIT the cachedDecisionOutcomeshort-circuits the sidecar round-trip; on MISS the wrap falls through toclient.reserve({ trigger: "LLM_CALL_PRE", … }). - On
DecisionDenied/DecisionStopped/DecisionSkipped/ApprovalRequired(without a resumer) /SidecarUnavailable, the adapter rethrows — the Inngest step body throws, Inngest records the step as failed, and the upstream LLM HTTP call NEVER leaves the process. Review-standards §3.1 + §5 enforced. - On
CONTINUE, the wrappedstep.ai.infer(...)runs verbatim against the inner namespace. The Inngest runtime orchestrates retries / replays as usual. - After the inner response returns, the bracket reads
usage.total_tokens(with fallback probe order for cross-provider robustness) and emits aSUCCESScommit viaclient.commitEstimated(...). Provider error → commit fires withoutcome="PROVIDER_ERROR"then the error rethrows.
Retry dedup — the headline contract
Section titled “Retry dedup — the headline contract”Inngest retries the same step body with the same step.id (and the
same step.idempotencyKey when one is supplied). The adapter feeds that
identity into SpendGuard’s idempotencyKey derivation, so:
- Attempt 0 fires
reserveagainst the sidecar. - Attempts 1+ (on retry) re-derive the same key. With
idempotencyCachesupplied, the cachedDecisionOutcomeshort-circuits the sidecar round-trip; without it, the sidecar’s own dedup layer absorbs the duplicate. - The SpendGuard audit chain records one
LLM_CALL_PRErow across N attempts — no double-billing on retry.
// retries: 2 means up to 3 attempts (0 + 2 retries).inngest.createFunction({ id: "agent-fn", retries: 2 }, ...);A NEW Inngest function invocation (new ctx.runId) for the same
step name produces a DIFFERENT idempotencyKey — fresh runs are NOT
deduped against prior runs. The identity tuple includes runId as a
scope seal.
attempt is intentionally NOT part of the seed (review-standards §6.5
— branch-covered).
Configuration
Section titled “Configuration”| Option | Type | Required | Description |
|---|---|---|---|
tenantId | string | YES | Tenant UUID the call is billed to. First field of the idempotency-key canonical tuple. |
budgetId | string | NO | Budget UUID used as the projected claim’s scopeId. When unset, the adapter falls back to tenantId. |
windowInstanceId | string | NO | Optional budget window UUID. Forwarded to the substrate when set. |
unit | UnitRef | NO | Defaults to { unit: “USD_MICROS”, denomination: 1 } on the commit path. |
pricing | PricingFreeze | NO | Empty-freeze default when unset; sidecar server-side defaults take over. |
claimEstimator | ClaimEstimator | NO | Maps ClaimEstimatorInput → readonly BudgetClaim[]. Default: zero-amount probe claim. Production consumers MUST override. |
route | string | NO | Defaults to “llm.call.inngest” (LOCKED, design.md §4). |
callSignatureFn | CallSignatureFn | NO | Optional signature override. Default: step identity drives derivation. |
claimEstimate | ClaimEstimate | NO | Higher-fidelity numeric hints forwarded verbatim on the reserve request. |
onApprovalRequired | (err, input) => Promise<DecisionOutcome|null|undefined> | NO | Resumes ApprovalRequired with the supplied outcome; null/undefined re-throws. |
idempotencyCache | IdempotencyCache | NO | Same-process cache (typically InMemoryIdempotencyCache from @spendguard/sdk). Drives the retry-dedup short-circuit; without it, layered defence via the sidecar’s own dedup still holds. |
Limitations
Section titled “Limitations”SpendGuard’s Inngest AgentKit adapter is pre-call reserve + post-call commit only. A few important things to know before you ship it in front of high-volume retry-heavy workloads:
- Stream-per-chunk gating is anti-scope for v0.1.x.
step.ai.inferis non-streaming by Inngest design (design.md§3 non-goal). The D04 / D06 / D08 composite demos’STREAMstep is replaced byRETRY_DEDUPin the D29 composite — the retry-safe reserve dedup is D29’s headline contract. - Retry-dedup short-circuit requires either an
idempotencyCacheOR sidecar-side dedup. Without an in-process cache, the wrap still emits byte-identicalidempotencyKeyto the sidecar on every attempt — the sidecar’s own idempotency layer catches the duplicate. The in-process cache pillar is the recommended configuration (saves a UDS round-trip per retry) but the layered defence is the safety guarantee. - Cross-step budget enforcement is out of scope. The adapter gates per-step. Multi-step budget contracts are the SpendGuard contract layer’s concern (see Contract YAML reference).
- Approval-resume UI is out of scope.
ApprovalRequiredflows through to the consumer’sonApprovalRequiredcallback if supplied; the adapter does NOT ship an approval UI. The Inngest function’s failure handler observes the error and can route it to your operator console. SidecarUnavailablepropagates as-is. Strict-mode default (review-standards §5.2 / §5.7). The futuredegrade=automode is LOCKED OUT of v0.1.x.- Browser is unsupported (D05 §6 UDS-only). The adapter runs only
in Node 20.10+ where
AsyncLocalStorageis available.
A bundled docker-compose demo proves the full ALLOW + DENY + RETRY_DEDUP matrix end-to-end against the sidecar + counting-stub upstream:
make demo-up DEMO_MODE=inngest_agent_kitThe mode boots postgres + sidecar + inngest-agent-kit-runner + counting-stub, then runs three invocations from the Node example at
examples/inngest-agent-kit/:
- ALLOW — small message within budget.
wrapWithSpendGuard’sreserve()returnsCONTINUE, the upstream HTTP call fires, counter+1, the bracket emits aSUCCESScommit. - DENY — body carries
spendguard_estimate_override=2000000000which blows past the seeded1Bhard-cap. The sidecar contract evaluator emitsSPENDGUARD_DENY, the bracket throwsDecisionDenied, the wrappedstep.ai.inferhalts BEFORE the upstream HTTP call. Counting-stub counter UNCHANGED (proves the gate fires pre-call). - RETRY_DEDUP — driver replays the SAME step body 3× with the SAME
(runId, step.id, idempotencyKey)and incrementedstep.attempt. The SpendGuard reserve fires EXACTLY ONCE across all 3 attempts. Counting-stub counter+3(one per attempt — the upstream HTTP layer still fires; the dedup happens above it at the SpendGuard reservation layer). The SQL gateCOV_D29_DEDUP_GATEasserts the ledger sees exactly 2 total reservations (ALLOW + RETRY_DEDUP) — not 4 (ALLOW + 3 attempts).
Success line on a clean run (LOCKED — CI greps for the exact spelling):
[demo] inngest_agent_kit ALL 3 steps PASS (ALLOW + DENY + RETRY_DEDUP)Full details and the verify-SQL gates are in
deploy/demo/inngest_agent_kit/README.md.
The standalone --mock mode (no sidecar required) runs against the same
factory + an in-process SpendGuardClient double:
cd examples/inngest-agent-kitpnpm installnode index.mjs --mockThe mock mode explicitly asserts the “DENY ⇒ inner step.ai is NEVER invoked” invariant and the “N retry attempts → 1 SpendGuard reservation” headline, exiting non-zero if either is violated.
Troubleshooting
Section titled “Troubleshooting”Common boot-time and first-call errors. The substrate’s typed exceptions
are re-exported from @spendguard/inngest-agent-kit so you can
pattern-match on DecisionDenied / SidecarUnavailable without taking
a second import.
-
DecisionDenied: reserve() denied by contract— Expected behaviour when the budget is exhausted or a contract rule emittedSPENDGUARD_DENY. The bracket rethrows, the Inngest step body throws, and the upstream LLM HTTP call never fires. Check the sidecar logs for the rule that matched; the decision row in the ledger carriesdecision_context.contract_rule. -
Budget gate not blocking — LLM call fires anyway. Almost always caused by
wrapWithSpendGuard(...)not being threaded into the function body. Inspect thesgStepvariable — it MUST be the wrappedstep.ai, not the rawstep.aireference passed by Inngest’s({ step })destructure. -
Retry is producing N reservations instead of 1. Either the consumer is not supplying an
idempotencyCacheAND the sidecar’s idempotency layer is misconfigured, OR each retry is somehow getting a freshrunId(verify the Inngest function’sretries:setting — Inngest reusesrunIdacross retries by default). TheCOV_D29_DEDUP_GATESQL fixture indeploy/demo/verify_step_inngest_agent_kit.sqlis the canonical regression test. -
SidecarUnavailable: handshake timeout— The sidecar’s UDS path (SPENDGUARD_SIDECAR_UDS, default/var/run/spendguard/adapter.sock) is not reachable. Check that the sidecar container is up, the socket file exists, and your Node process has read/write permission on it. -
ApprovalRequiredpropagates without your resume callback firing. TheonApprovalRequiredoption must return aDecisionOutcome(not null/undefined) to resume. Anullreturn causes the original error to propagate unchanged —Runner.run(...)halts and the step body errors out for Inngest’s retry layer to observe. -
Token counts on commit don’t match what the provider reports. The bracket’s
extractTotalTokens(result)probes (in order)result.usage.total_tokens→result.usage_metadata.total_tokens→result.response_metadata.token_usage.total_tokens. If none are present (rare; defensive fallback), the commit ships0for the missing field — check the provider’s raw response shape. -
Class identity check fails:
err instanceof DecisionDeniedis false — You importedDecisionDeniedfrom@spendguard/sdkdirectly while the wrap threw from@spendguard/inngest-agent-kit. Class identity IS preserved (re-export, not subclass), so the check works regardless of which import you use. If the check fails, suspect a duplicate@spendguard/sdkin yournode_modulestree.
Related
Section titled “Related”- Quickstart — full SpendGuard stack up in 5 minutes.
- LangChain.js callback handler — LangChain.js callback-handler sibling adapter.
- Vercel AI SDK (covers Mastra) — Vercel AI middleware sibling.
- OpenAI Agents SDK (TypeScript) — OpenAI Agents SDK TS sibling.
- Contract YAML reference — author allow/stop rules.
- Other adapter integrations: Pydantic-AI · LangChain & LangGraph (Python) · OpenAI Agents SDK (Python) · Microsoft AGT · LiteLLM proxy guardrail