Inngest Agent Evals
Use this skill when the user wants to evaluate AI agents or AI workflows in
production, add scoring, compare prompts/models/tools, group related runs, or
debug why an agent outcome was good or bad.
Agent Evals is not a separate package. It is the production evaluation workflow
built from Inngest functions, durable steps, scores, deferred scorers, sessions,
traces, experiments, and Insights.
Canonical References
Check the public docs first when exact API details matter:
Decision Flow
Start from the outcome, not the mechanism:
- Identify the product or quality signal: helpful click, ticket resolution,
conversion, guardrail pass, retrieval quality, model confidence, latency,
cost, human review, or LLM-as-judge result.
- Decide when the signal appears:
- During the run: use direct scoring with
step.score() or
inngest.score().
- After the run: use deferred scoring with
createScorer() and defer(),
or explicit inngest.score({ runId }).
- Decide how humans will inspect related work:
- Use
meta.sessions for repeated high-cardinality identifiers such as
conversation_id, ticket_id, agent_run_id, or import_id.
- Use Insights for ad hoc historical analysis, low-cardinality filters, SQL
aggregates, and broad investigation.
- Decide whether to compare variants:
- Use
group.experiment() for prompt, model, provider, tool, workflow, or
rollout comparisons.
- Pass
experimentRef to deferred or later scores when the score should be
credited to the selected variant.
- Preserve traceability:
- Keep model calls, tool calls, waits, database writes, and scoring work in
durable steps so traces explain why the score happened.
Setup Requirements
- Use TypeScript SDK v4 and install the latest SDK:
npm install inngest@latest.
- Scoring and deferred scoring are beta APIs; verify current imports against
the docs before shipping user-facing examples.
step.score() requires scoreMiddleware() from inngest/experimental on
the Inngest client.
createScorer() is imported from inngest/experimental and the scorer must
be registered in the serve handler with other functions.
- Sessions sent through the TypeScript SDK require v4.7.0 or later.
- Step experiments require v4.8.0 or later for
group.experiment() and the
experiment helper from inngest.
Client shape:
import { Inngest } from "inngest";
import { scoreMiddleware } from "inngest/experimental";
export const inngest = new Inngest({
id: "support-agent",
middleware: [scoreMiddleware()],
});
Sessions
Add sessions when events belong to a user flow, conversation, ticket, import,
or agent task that someone will inspect repeatedly.
await inngest.send({
name: "support/ticket.created",
data: {
ticketId: "tk_123",
message: "I can't sign in.",
},
meta: {
sessions: {
ticket_id: "tk_123",
},
},
});
Rules:
- Use stable, non-secret, high-cardinality IDs.
- Keep the key generic, such as
conversation_id; put the actual ID in the
value.
- Pass sessions explicitly through
step.invoke() and step.sendEvent() when
downstream runs should join the same session.
- Do not use sessions for labels like
environment: prod; use Insights for
that style of filtering.
Direct Scoring
Use direct scoring when the score is known during the function run.
Good direct scores:
- guardrail pass/fail
- JSON validity
- retrieval confidence
- tool success
- model confidence
- inline LLM-as-judge result
export const answerTicket = inngest.createFunction(
{
id: "answer-ticket",
triggers: [{ event: "support/ticket.created" }],
},
async ({ event, step }) => {
const answer = await step.run("generate-answer", () =>
generateAnswer(event.data.ticketId)
);
const passed = await step.run("check-answer", () => validateAnswer(answer));
await step.score("score-answer-quality", {
name: "answer-quality",
value: passed,
});
return { answer, passed };
}
);
Use stable score names. Changing a score name creates a separate metric.
Score values must be finite numbers or booleans.
Deferred Scoring
Use deferred scoring when the useful signal arrives after the parent workflow
finishes, such as user feedback, conversion, ticket reopen, retention, or a
slow LLM-as-judge run.
import { createScorer } from "inngest/experimental";
import { z } from "zod";
export const feedbackScorer = createScorer(
inngest,
{
id: "support-feedback-scorer",
schema: z.object({ ticketId: z.string() }),
},
async ({ event, step }) => {
const feedback = await step.waitForEvent("wait-for-feedback", {
event: "support/feedback.received",
timeout: "7d",
if: `async.data.ticketId == '${event.data.ticketId}'`,
});
return {
name: "user-feedback",
value: feedback?.data.helpful ? 1 : 0,
};
}
);
Trigger it from the producing function:
async ({ event, step, defer }) => {
const answer = await step.run("generate-answer", () =>
generateAnswer(event.data.ticketId)
);
defer("score-feedback", {
function: feedbackScorer,
data: { ticketId: event.data.ticketId },
});
return { answer };
}
Guardrails:
- Register scorers with
serve({ functions: [...] }).
defer() is fire-and-forget; do not await it.
- The parent run is attributed automatically for deferred scorers.
- Return a default score or
null when a signal times out, based on the
product semantics.
Experiments
Use group.experiment() when comparing prompts, models, providers, tools,
workflow rewrites, or operational settings against real traffic.
import { experiment } from "inngest";
const { result, variant, experimentRef } = await group.experiment(
"answer-style",
{
variants: {
concise: () => step.run("answer-concise", () => answerConcise(event.data)),
detailed: () =>
step.run("answer-detailed", () => answerDetailed(event.data)),
},
select: experiment.bucket(event.data.accountId, {
weights: { concise: 50, detailed: 50 },
}),
}
);
Selection strategy:
experiment.weighted() for run-level traffic splits.
experiment.bucket(stableId, { weights }) when a user, account, or tenant
should usually keep the same experience.
experiment.custom() when assignment comes from a database, flag service, or
rollout table.
experiment.fixed() to force one variant during testing or after choosing a
winner.
Rules:
- Each variant callback must call at least one
step.* tool.
- Keep experiment IDs and variant names stable because they appear in traces.
- The selected variant is memoized for retries and replays.
- Persist
experimentRef and the parent run ID if a later, separate process
will score the selected variant.
When deferred scoring a variant, pass the ref:
defer("score-answer-feedback", {
function: feedbackScorer,
data: { ticketId: event.data.ticketId },
experiment: experimentRef,
});
When scoring from a later run explicitly:
await inngest.score.experiment({
name: "clickthrough",
value: 1,
experiment: experimentRef,
runId: originalRunId,
});
Brownfield Migration
When adding Agent Evals to an existing app:
- Search for existing agent/workflow outputs and where users act on them.
- Find durable identifiers already in the domain: conversation ID, ticket ID,
account ID, import ID, run ID, or recommendation ID.
- Add
meta.sessions at event producers before building dashboards around
correlation.
- Add one direct score where the outcome is already known.
- Add a deferred scorer only after the signal event exists or can be emitted
cleanly.
- Add
group.experiment() only around one isolated decision at a time.
- Keep old prompt/model/tool behavior stable until scoring proves the new path.
Use inngest-brownfield-audit first when the repo has many candidate
workflows. Use inngest-agents with this skill when the work is an AgentKit or
durable agent workflow.
Anti-Patterns
- Starting with a prompt experiment before naming the outcome metric.
- Scoring with unstable names, strings, objects,
NaN, or Infinity.
- Forgetting
scoreMiddleware() and then assuming step.score() is missing.
- Running scorers in an external queue when the scorer needs durable waits.
- Keeping experiment assignment only in memory.
- Scoring an experiment variant from a later run without the original
runId.
- Using sessions for low-cardinality labels or sensitive personal data.
- Leaving model/tool work outside steps, making traces unable to explain the
score.
Verification
- Typecheck the Inngest client, functions, scorer schemas, and serve handler.
- Confirm scorers are registered with the serve endpoint.
- Test the producer emits events with expected
meta.sessions.
- Test direct scores use stable names and finite number/boolean values.
- Test deferred scorer timeout behavior.
- For experiments, test that each variant callback contains step work and that
later scoring receives
experimentRef plus the original run ID when needed.
- If possible, run the Inngest dev server and inspect traces, sessions, scores,
and experiment variant selection in the dashboard or through available CLI/API
tooling.
1---2name: inngest-agent-evals3description: Use when building, migrating, or debugging Agent Evals on Inngest: scoring AI agent or workflow outcomes, deferred scorers, sessions, traces, step experiments, experiment variant attribution, Insights queries, or production eval loops for prompts, models, tools, providers, and agent behavior. Covers TypeScript SDK v4 scoring beta APIs, `scoreMiddleware`, `step.score`, `inngest.score`, `createScorer`, `defer`, `group.experiment`, `experimentRef`, `meta.sessions`, and when to use durable workflow primitives for outcome-based evaluation.4---5
6# Inngest Agent Evals
7
8Use this skill when the user wants to evaluate AI agents or AI workflows in
9production, add scoring, compare prompts/models/tools, group related runs, or
10debug why an agent outcome was good or bad.
11
12Agent Evals is not a separate package. It is the production evaluation workflow
13built from Inngest functions, durable steps, scores, deferred scorers, sessions,
14traces, experiments, and Insights.
15
16## Canonical References
17
18Check the public docs first when exact API details matter:
19
20- Agent Evals overview: https://www.inngest.com/docs/learn/agent-evals
21- Scoring guide: https://www.inngest.com/docs/features/inngest-functions/steps-workflows/scoring
22- Deferred scoring guide: https://www.inngest.com/docs/features/inngest-functions/steps-workflows/deferred-scoring
23- Step experiments guide: https://www.inngest.com/docs/features/inngest-functions/steps-workflows/step-experiments
24- Sessions: https://www.inngest.com/docs/features/events-triggers/sessions
25- Traces: https://www.inngest.com/docs/platform/monitor/traces
26- Insights: https://www.inngest.com/docs/platform/monitor/insights
27- Scoring reference: https://www.inngest.com/docs/reference/typescript/v4/functions/scoring
28- `group.experiment()` reference: https://www.inngest.com/docs/reference/typescript/v4/functions/group-experiment
29- Launch context: https://www.inngest.com/blog/introducing-agent-evals
30
31## Decision Flow
32
33Start from the outcome, not the mechanism:
34
351. Identify the product or quality signal: helpful click, ticket resolution,
36 conversion, guardrail pass, retrieval quality, model confidence, latency,
37 cost, human review, or LLM-as-judge result.
382. Decide when the signal appears:
39 - During the run: use direct scoring with `step.score()` or
40 `inngest.score()`.
41 - After the run: use deferred scoring with `createScorer()` and `defer()`,
42 or explicit `inngest.score({ runId })`.
433. Decide how humans will inspect related work:
44 - Use `meta.sessions` for repeated high-cardinality identifiers such as
45 `conversation_id`, `ticket_id`, `agent_run_id`, or `import_id`.
46 - Use Insights for ad hoc historical analysis, low-cardinality filters, SQL
47 aggregates, and broad investigation.
484. Decide whether to compare variants:
49 - Use `group.experiment()` for prompt, model, provider, tool, workflow, or
50 rollout comparisons.
51 - Pass `experimentRef` to deferred or later scores when the score should be
52 credited to the selected variant.
535. Preserve traceability:
54 - Keep model calls, tool calls, waits, database writes, and scoring work in
55 durable steps so traces explain why the score happened.
56
57## Setup Requirements
58
59- Use TypeScript SDK v4 and install the latest SDK: `npm install inngest@latest`.
60- Scoring and deferred scoring are beta APIs; verify current imports against
61 the docs before shipping user-facing examples.
62- `step.score()` requires `scoreMiddleware()` from `inngest/experimental` on
63 the Inngest client.
64- `createScorer()` is imported from `inngest/experimental` and the scorer must
65 be registered in the serve handler with other functions.
66- Sessions sent through the TypeScript SDK require v4.7.0 or later.
67- Step experiments require v4.8.0 or later for `group.experiment()` and the
68 `experiment` helper from `inngest`.
69
70Client shape:
71
72```typescript
73import { Inngest } from "inngest";
74import { scoreMiddleware } from "inngest/experimental";
75
76export const inngest = new Inngest({
77 id: "support-agent",
78 middleware: [scoreMiddleware()],
79});
80```
81
82## Sessions
83
84Add sessions when events belong to a user flow, conversation, ticket, import,
85or agent task that someone will inspect repeatedly.
86
87```typescript
88await inngest.send({
89 name: "support/ticket.created",
90 data: {
91 ticketId: "tk_123",
92 message: "I can't sign in.",
93 },
94 meta: {
95 sessions: {
96 ticket_id: "tk_123",
97 },
98 },
99});
100```
101
102Rules:
103
104- Use stable, non-secret, high-cardinality IDs.
105- Keep the key generic, such as `conversation_id`; put the actual ID in the
106 value.
107- Pass sessions explicitly through `step.invoke()` and `step.sendEvent()` when
108 downstream runs should join the same session.
109- Do not use sessions for labels like `environment: prod`; use Insights for
110 that style of filtering.
111
112## Direct Scoring
113
114Use direct scoring when the score is known during the function run.
115
116Good direct scores:
117
118- guardrail pass/fail
119- JSON validity
120- retrieval confidence
121- tool success
122- model confidence
123- inline LLM-as-judge result
124
125```typescript
126export const answerTicket = inngest.createFunction(
127 {
128 id: "answer-ticket",
129 triggers: [{ event: "support/ticket.created" }],
130 },
131 async ({ event, step }) => {
132 const answer = await step.run("generate-answer", () =>
133 generateAnswer(event.data.ticketId)
134 );
135
136 const passed = await step.run("check-answer", () => validateAnswer(answer));
137
138 await step.score("score-answer-quality", {
139 name: "answer-quality",
140 value: passed,
141 });
142
143 return { answer, passed };
144 }
145);
146```
147
148Use stable score names. Changing a score name creates a separate metric.
149Score values must be finite numbers or booleans.
150
151## Deferred Scoring
152
153Use deferred scoring when the useful signal arrives after the parent workflow
154finishes, such as user feedback, conversion, ticket reopen, retention, or a
155slow LLM-as-judge run.
156
157```typescript
158import { createScorer } from "inngest/experimental";
159import { z } from "zod";
160
161export const feedbackScorer = createScorer(
162 inngest,
163 {
164 id: "support-feedback-scorer",
165 schema: z.object({ ticketId: z.string() }),
166 },
167 async ({ event, step }) => {
168 const feedback = await step.waitForEvent("wait-for-feedback", {
169 event: "support/feedback.received",
170 timeout: "7d",
171 if: `async.data.ticketId == '${event.data.ticketId}'`,
172 });
173
174 return {
175 name: "user-feedback",
176 value: feedback?.data.helpful ? 1 : 0,
177 };
178 }
179);
180```
181
182Trigger it from the producing function:
183
184```typescript
185async ({ event, step, defer }) => {
186 const answer = await step.run("generate-answer", () =>
187 generateAnswer(event.data.ticketId)
188 );
189
190 defer("score-feedback", {
191 function: feedbackScorer,
192 data: { ticketId: event.data.ticketId },
193 });
194
195 return { answer };
196}
197```
198
199Guardrails:
200
201- Register scorers with `serve({ functions: [...] })`.
202- `defer()` is fire-and-forget; do not `await` it.
203- The parent run is attributed automatically for deferred scorers.
204- Return a default score or `null` when a signal times out, based on the
205 product semantics.
206
207## Experiments
208
209Use `group.experiment()` when comparing prompts, models, providers, tools,
210workflow rewrites, or operational settings against real traffic.
211
212```typescript
213import { experiment } from "inngest";
214
215const { result, variant, experimentRef } = await group.experiment(
216 "answer-style",
217 {
218 variants: {
219 concise: () => step.run("answer-concise", () => answerConcise(event.data)),
220 detailed: () =>
221 step.run("answer-detailed", () => answerDetailed(event.data)),
222 },
223 select: experiment.bucket(event.data.accountId, {
224 weights: { concise: 50, detailed: 50 },
225 }),
226 }
227);
228```
229
230Selection strategy:
231
232- `experiment.weighted()` for run-level traffic splits.
233- `experiment.bucket(stableId, { weights })` when a user, account, or tenant
234 should usually keep the same experience.
235- `experiment.custom()` when assignment comes from a database, flag service, or
236 rollout table.
237- `experiment.fixed()` to force one variant during testing or after choosing a
238 winner.
239
240Rules:
241
242- Each variant callback must call at least one `step.*` tool.
243- Keep experiment IDs and variant names stable because they appear in traces.
244- The selected variant is memoized for retries and replays.
245- Persist `experimentRef` and the parent run ID if a later, separate process
246 will score the selected variant.
247
248When deferred scoring a variant, pass the ref:
249
250```typescript
251defer("score-answer-feedback", {
252 function: feedbackScorer,
253 data: { ticketId: event.data.ticketId },
254 experiment: experimentRef,
255});
256```
257
258When scoring from a later run explicitly:
259
260```typescript
261await inngest.score.experiment({
262 name: "clickthrough",
263 value: 1,
264 experiment: experimentRef,
265 runId: originalRunId,
266});
267```
268
269## Brownfield Migration
270
271When adding Agent Evals to an existing app:
272
2731. Search for existing agent/workflow outputs and where users act on them.
2742. Find durable identifiers already in the domain: conversation ID, ticket ID,
275 account ID, import ID, run ID, or recommendation ID.
2763. Add `meta.sessions` at event producers before building dashboards around
277 correlation.
2784. Add one direct score where the outcome is already known.
2795. Add a deferred scorer only after the signal event exists or can be emitted
280 cleanly.
2816. Add `group.experiment()` only around one isolated decision at a time.
2827. Keep old prompt/model/tool behavior stable until scoring proves the new path.
283
284Use `inngest-brownfield-audit` first when the repo has many candidate
285workflows. Use `inngest-agents` with this skill when the work is an AgentKit or
286durable agent workflow.
287
288## Anti-Patterns
289
290- Starting with a prompt experiment before naming the outcome metric.
291- Scoring with unstable names, strings, objects, `NaN`, or `Infinity`.
292- Forgetting `scoreMiddleware()` and then assuming `step.score()` is missing.
293- Running scorers in an external queue when the scorer needs durable waits.
294- Keeping experiment assignment only in memory.
295- Scoring an experiment variant from a later run without the original `runId`.
296- Using sessions for low-cardinality labels or sensitive personal data.
297- Leaving model/tool work outside steps, making traces unable to explain the
298 score.
299
300## Verification
301
302- Typecheck the Inngest client, functions, scorer schemas, and serve handler.
303- Confirm scorers are registered with the serve endpoint.
304- Test the producer emits events with expected `meta.sessions`.
305- Test direct scores use stable names and finite number/boolean values.
306- Test deferred scorer timeout behavior.
307- For experiments, test that each variant callback contains step work and that
308 later scoring receives `experimentRef` plus the original run ID when needed.
309- If possible, run the Inngest dev server and inspect traces, sessions, scores,
310 and experiment variant selection in the dashboard or through available CLI/API
311 tooling.