Skip to content

Server-side event ingest

The VEKTIS tracker SDK is a thin client over a single HTTP endpoint. If you need to send events from a backend service, a batch job, a mobile-server pipeline, or anything else that isn’t a browser, you can call that endpoint directly.

This page is the HTTP reference. The browser SDK is documented under VEKTIS Tracker — pick that if your events originate on the client.

POST https://events.vektis.io/api/v1/events
Content-Type: application/json
X-Vektis-Key: vk_prd_...

Content-Type: application/json is required. Requests without it return 415 Unsupported Media Type.

Pass your VEKTIS API key in the X-Vektis-Key header. Direct (non-browser) callers should always use the header.

For browsers using navigator.sendBeacon() on page-unload — where custom headers can’t be set — a ?key=<key> query-param fallback is accepted. The browser SDK uses this path automatically; server callers should not.

For server-side calls, use a Full (server-side) key (prefix vk_<env>_..., e.g. vk_prd_... for production), created on the API Keys page. The browser-safe Publishable keys (vk_pub_<env>_...) that the tracker SDK uses are also accepted, but they carry tighter rate limits — prefer a Full key from a backend caller.

Three things to know before you go and create one:

  • The scope selector starts on Publishable. Change it to Full, or you get a browser key with the tighter limits. The reliable tell is the prefix: a Full key has no pub_ segment.
  • The environment selector starts on Development, which gives you a vk_dev_... key. Switch it to Production for production traffic. Environment is fixed once the key exists — to change it, create another key.
  • Only an admin can create a key, and an organization can hold 10 active keys at a time. Revoke an unused one if you hit that.

Measurement is environment-blind: events sent with a development or staging key join the same population as production events for the same Feature ID. Send production traffic with a production key.

The body is always a events array, even for a single event:

{
"events": [ /* 1 to 100 TrackingEvent objects */ ]
}
ConstraintValue
Minimum events per request1
Maximum events per request100
Maximum body size512 KB
FieldRequiredTypeNotes
event_idyesstring (UUID)Caller-generated UUID. Generate this when the event happens, not on retry — see Idempotency.
event_typeyesenumOne of the five values in Event types. Other values are rejected.
customer_idyesstring, 1–255 charsTop-level on each event, not inside properties.
feature_idrequired for feature.* eventsstring, 1–255 charsfeature.used, feature.engagement, and feature.first_use all require this.
user_idfor seat metricsstring, ≤255 charsOptional at the API — an event without it is accepted and still counts toward overall and per-customer impact, but contributes no seat signal. Top-level on each event, not inside properties. Some transports have no user to send; see Identifying the user.
actionfor scoringstring, ≤255 charsOptional at the API. Every Value Type that is derived from events matches on it — all but Overall activity and Revenue (manual) — and an event whose action name doesn’t match the dev item is never counted. Keep names to 200 characters or fewer — that is the limit on the dev item field, so a longer name can be accepted here and still never match. See Making events scoreable.
propertiesnoobjectSee constraints below.
timestampnoISO 8601 with offset (e.g., 2026-05-25T14:30:00Z)Must fall within 7 days in the past to 1 hour in the future of server time. If omitted, the event is stamped on receipt — so if you buffer before flushing, set it explicitly. A Time to complete metric is measured from these stamps, and an event timed at flush skews it.
ConstraintValue
Maximum keys50
Maximum key length64 characters
Maximum string value length1024 characters
Maximum total JSON size8 KB
Allowed value typesstring, number, boolean

event_type is a fixed enum. Other values return 400 Bad Request.

event_typeNotes
feature.usedPrimary event — a customer used a feature. Requires feature_id.
feature.engagementEngagement signal within a feature (e.g., depth of use). Requires feature_id.
feature.first_useFirst-time-use signal for a customer/feature pair. Requires feature_id. Optional: VEKTIS already derives each customer’s first use from the earliest event it holds for that Feature ID, so you do not have to track firstness yourself — no state, no “have I seen this customer before” lookup.
session.activeLiveness for one customer. Counted as distinct customers seen, never as volume: extra events for a customer who is already sending events change nothing. Sending it for customers who never touch a feature lowers that feature’s Overall activity score — see What each Value Type counts.
customer.identifiedAssociates a customer_id with a user_id, where you have one — see Identifying the user. Non-billable — does not count against your event quota.

Sending a valid event is not the same as sending a scoreable one. VEKTIS matches an incoming event to a dev item on two things:

  1. the Feature ID, and
  2. the action name.

Both must be present, and the action name must be character-for-character the one configured on the dev item. You set both under Edit dev item → “How should VEKTIS measure this?” → VEKTIS measures it for me, in the fields labelled Feature ID, Action name, Numerator action, Denominator action, Start action and End action.

If the two strings disagree — or if you send no action name at all — the metric stays empty. It is not an error and the request still returns 202.

Two Value Types are exceptions. Overall activity has no action field to configure and matches on Feature ID alone. Revenue (manual) is not derived from events at all, so there is nothing to match — see Which event type to send. Everything below that talks about matching action names applies to the other three.

Everything you send is keyed to the dev item’s Value Type — the dropdown under “How should VEKTIS measure this?”. These five, named exactly as the dropdown names them:

Value TypeAction fields on the dev itemWhat you send
Count occurrencesAction nameone feature.used per occurrence
Conversion rateDenominator actionfeature.engagement when the attempt starts
Numerator actionfeature.used when it succeeds
Time to completeStart action, End actionfeature.engagement for each, one at the start and one at the end
Overall activitynone — nothing to configurefeature.engagement at every meaningful interaction
Revenue (manual)nonenothing — see below

Two consequences worth reading twice:

  • Conversion rate and Time to complete each need two different action names, one per event. Sending the same action for both halves gives VEKTIS nothing to divide or subtract.
  • Revenue (manual) is not derived from events. Those numbers are entered by hand. Instrumenting a revenue feature produces billable events that can never turn into a metric.

The event type does not change how VEKTIS matches or scores an event — matching is on Feature ID plus action name. What the feature. prefix does control is that feature_id becomes required. So sending feature.engagement where the table says feature.used will not lose your data; use the table so your events read the way the rest of the product describes them.

Metrics are derived one week at a time. Both halves of a two-event measurement have to land inside the same week, which matters most for Time to complete.

Count occurrences — how many events carried the Action name that week. Events, not customers: ten calls from one customer count as ten.

Conversion ratedistinct customers that sent the numerator action, divided by distinct customers that sent the denominator action, that week. It is not a ratio of event counts, so a customer retrying five times still counts once on each side. Send the denominator when the attempt begins and the numerator only when it succeeds — emit both at the success point and the rate sits at 100% forever.

Time to complete — VEKTIS pairs each start with the next end for the same customer_id, oldest first, and averages the gaps.

  • customer_id is the whole pairing key. user_id is not part of it, and neither is any property you attach. If two attempts are open at once under one customer, the first start pairs with the first end regardless of which attempt they belong to — so for a feature that runs concurrently per customer, treat the average as approximate.
  • A start with no end in the same week, and an end whose start fell in the previous week, are both dropped from that week’s average.
  • Send an explicit timestamp on both events. An event without one is stamped on arrival, so any buffering or retry delay lands directly in the measurement.

Overall activity — no action names, no configuration, nothing to keep in sync. It combines reach (how many of your active customers sent any event for this Feature ID) with depth (how many events each of them sent), so:

  • Action names here are free-form. Nothing matches on them. Use a distinct short one per call site — viewed, searched, exported — so the events stay readable to you.
  • Every event for the Feature ID adds depth, whatever its action name or event type.
  • The denominator is every customer who sent VEKTIS any event at all that week, for any feature. This is the one case where instrumenting more of your product lowers a score: a customer marked active who never reaches this feature makes its reach look smaller.

Send one event, then open the dev item.

You do not have to catch it live, and there is no dialog to leave open — a server-side install is never confirmed in the moment you run it. Come back to the dev item after your first event and use the check below.

  • No warning means the Feature ID and the action name both matched. You are done.
  • A warning names the action name the dev item is configured for and lists the action names actually arriving, so you can see the mismatch side by side. An event sent with no action name appears in that list as (no action name).
  • The summary at the top of the dev item also stops naming a scoring date while this is unresolved, because that date can no longer be met.

The warning lists each action name it is receiving, with how many events carried it. Pick the right one and VEKTIS starts measuring it straight away — no retyping. If none of them is right, change what your code sends instead. Events that arrived with no action name at all cannot be picked; those you fix in your code.

Roughly, and for readability only — the binding rule is the Value Type table above.

Event typeOn an APIOn an MCP server
feature.usedThe endpoint representing that feature was calledA tool call that did real work for the customer
feature.engagementThe richness of the request — query complexity, payload depthThe richness of what came back — result size, fields returned
session.activeA burst of activity from one customerA burst of tool calls in one agent session
feature.first_useThe first time this customer called the endpointThe first time this customer’s agent invoked the tool
customer.identifiedThe authenticated identity behind the callThe identity in the server’s auth context

Capture the shape of a request in properties — how much was asked for, how much came back — never the contents of the data itself.

MCP: count one successful terminal tool call

Section titled “MCP: count one successful terminal tool call”

“A tool was called” is narrower than it sounds. One human question can produce five to fifty tool calls: pagination, retries after a malformed argument, multi-step prompts for missing input, exploratory queries the model discards. Counting all of them inflates the score the same way a cron job would, and a customer who switches to a chattier model watches their score rise with no change in real usage.

Emit feature.used only for a successful terminal tool call. All four conditions must hold. Conditions 1–3 are read off the MCP specification, revision 2026-07-28; resultType and the multi-round-trip flow it belongs to were introduced there by SEP-2322.

#ConditionExcludesWhere it lives
1No JSON-RPC protocol errorunknown tool, malformed requestprotocol
2isError is not truethe retry-after-bad-args loopprotocol
3resultType is "complete" or absentmulti-round-trip intermediatesprotocol
4Args do not carry the tool’s own continuation/paging argumentpagination fan-outper-tool
  • Condition 2 is exact, not a heuristic. The MCP specification defines tool execution errors (isError: true) as “actionable feedback that language models can use to self-correct and retry with adjusted parameters” — that category is the retry class.
  • Condition 3 treats an absent resultType as completed, so servers on specification revisions before 2026-07-28 are unaffected. Do not require the newest revision. The field is also not yet in the @modelcontextprotocol/sdk TypeScript types (as of 1.29.0), so read it defensively — as the example below does — rather than off the declared CallToolResult shape.
  • Condition 4 is not a protocol field. cursor and nextCursor belong to tools/list pagination and do not appear on tools/call. A paging token on a tool call is whatever that tool’s author declared in its own inputSchema. Name the continuation argument for each tool you instrument, or confirm the tool has none — there is no generic field to check.
  • requestState and inputResponses do NOT exclude. A multi-step flow produces exactly one complete result: the first call returns resultType: "input_required" (already excluded by condition 3), and the follow-up — which carries inputResponses, and requestState where the server supplied it — is the one that succeeds. Excluding on those fields would drop the whole interaction and score every prompt-for-input tool zero. Condition 3 alone counts each chain exactly once.

A call that fails these conditions is not emitted at all. Do not emit it marked as automated traffic — see below.

Which event types the conditions govern:

Event typeGated by the four conditions?
feature.usedYes — the rule exists for it
feature.first_useYes — it marks a customer’s first real invocation, so pinning it to a call that errored or was a pagination page is wrong. Optional either way: VEKTIS derives first use from the earliest event it holds for the Feature ID
session.activeNo — it is a liveness signal for one agent session, not a per-call event. Cadence does not matter, because it is counted as distinct customers seen: one per agent session is plenty, and a hundred more for the same customer change nothing. Skip it entirely if that customer is already sending feature events

Telling customer activity from automated traffic

Section titled “Telling customer activity from automated traffic”

Cron jobs, health checks and polling are not customers seeking value, and left unfiltered they measure your infrastructure instead of your product.

Mark an event by putting is_agent_call in properties, as a JSON boolean literal:

"properties": { "is_agent_call": true }

true and false, never the strings "true" / "false", never 0 / 1.

  • Absent means counted. An event with no marker is treated as customer activity. “Not explicitly marked as intent” is never treated as noise.
  • The two surfaces label differently. On an MCP server the instrumentation lives inside your tool handler, so every event it emits is an agent call by construction — set true and move on. On an API, the handler already knows whether it is an intent endpoint or a cron/health path, so label from that context.
  • On an MCP server, true means something narrower than it does on an API: not “an agent called this” but “one successful terminal tool call”, per the four conditions above.

customer_id is required on every event, and it is what separates one customer from another in every metric. On an HTTP API it is native to your auth context. On an MCP server it frequently is not.

TransportWhere customer_id comes from
Authenticated remote / HTTP MCPA stable claim on the OAuth token
Unauthenticated remote / HTTP MCPAn environment variable — treat it like stdio
Local / stdio MCPAn environment variable from the env block of the client’s server config
  • Authenticated remote / HTTP MCP. Where your server implements authorization, Authorization: Bearer must be present on every request, which fits MCP’s stateless model exactly. Validate the token audience, then derive customer_id from a stable claim — sub for a user, or a tenant claim where you bill accounts rather than users. If the token is opaque rather than a JWT the claim is not readable at your server: use token introspection, or a server-side mapping from token to customer. Do not parse an opaque token.
  • Unauthenticated remote / HTTP MCP. Authorization is optional in MCP; an unauthenticated remote server is legal and has no token at all. Treat it like stdio.
  • Local / stdio MCP. The specification is explicit that stdio implementations should retrieve credentials from the environment rather than implementing authorization. So customer_id comes from the env block of the client’s server configuration, set at install time alongside your VEKTIS API key. That is the sanctioned path, not a workaround.

user_id is optional, and unlike customer_id there are transports where a user does not exist at all. The rule is short: where an authenticated person is behind the call, send a stable identifier for them; where there isn’t one, send nothing.

It is the only input to the Seats number on a dev item’s Customer Breakdown — how many people at a customer used the feature, out of everyone there VEKTIS has seen using your product.

TransportWhere user_id comes from
Authenticated HTTP APIYour auth context — see the API example
Authenticated remote / HTTP MCP, customer_id from a tenant claimThe sub claim
Authenticated remote / HTTP MCP, customer_id from subNone separate — the account is the person. Omit it
Unauthenticated remote / HTTP MCPNone available — no token, no person. Omit it
Local / stdio MCPNone available — the env block identifies an install, not a person. Omit it
  • Authenticated HTTP API. Your auth context has already resolved the person. This is the case the examples on this page show.
  • Authenticated remote / HTTP MCP. Which claim is left for the user depends on the choice you made for customer_id above. Where you derived customer_id from a tenant claim, sub identifies the person and is what you send. Where you derived customer_id from sub itself — because you bill users rather than accounts — there is no second identifier: the account and the person are the same, and sending sub as both produces a Seats number of 1 of 1 for every customer. That is not a seat metric. Omit user_id.
  • Unauthenticated remote / HTTP MCP. No token means no person. Omit it.
  • Local / stdio MCP. customer_id comes from the env block of the client’s server configuration, which identifies an install rather than a person. A second environment variable does not fix this — whoever set it up is not necessarily whoever is calling. Omit it.

What omitting it looks like. Everything else works normally: the event is accepted, and it counts toward overall and per-customer impact exactly as it would otherwise. What you don’t get is the Seats number — that customer’s Seats cell shows a dash, and where no customer has a number the column is hidden entirely. See Customer Breakdown.

Send a stable opaque application user id — your own internal identifier for that person, stable across their sessions, carrying no personal information.

Don’t sendWhy
A raw email addressPersonal data outright.
A hashed email addressReversible in practice against any list of email addresses — personal data with extra steps.
A per-session or per-request idDefeats the purpose. Counting seats needs the same person to resolve to the same value across the whole measurement window.

VEKTIS uses the value to count distinct users within a feature, and that count is the only thing it produces. No screen, export or drilldown ever renders the identifier, and there is no per-person view of behaviour across features at any level of detail. The value is stored on the event and is deleted when the event is.

There is no dedicated /identify endpoint. To identify a customer from a server, post a customer.identified event:

{
"events": [{
"event_id": "550e8400-e29b-41d4-a716-446655440000",
"event_type": "customer.identified",
"customer_id": "acct_A1",
"user_id": "user_123",
"properties": { "name": "Acme Corp" }
}]
}

properties.name is optional and is the only way to put a readable account name on the Customer Breakdown — without it, every row there is labelled with the raw customer_id. Send the account’s display name, not a person’s. The most recent non-empty name wins, so re-sending it as the account renames itself is enough to keep it current; names longer than 255 characters are truncated.

{ "accepted": 3 }

accepted is the number of events that passed validation and were queued for ingestion. It is not a count of stored rows — duplicates by event_id are dropped silently during async processing (see Idempotency).

All errors return JSON with at least statusCode and message:

{
"statusCode": 400,
"message": "Validation failed",
"errors": [ /* per-field issues */ ]
}
StatusWhenNotable body fields
400 Bad RequestSchema validation failed, or the body was not valid JSONerrors[] (per-field issues, present for schema failures)
401 UnauthorizedAPI key is missing or invalid
413 Payload Too LargeRequest body exceeded 512 KB
415 Unsupported Media TypeContent-Type was not application/json
429 Too Many RequestsRate limit exceededretryAfter (seconds); also returned as the Retry-After HTTP header
500 Internal Server ErrorUnhandled server error

The server deduplicates events by event_id scoped to your account. Posting the same event_id twice results in the duplicate being dropped silently — no error, no double-count.

Two implications:

  1. accepted reflects queued events, not stored rows. If you re-send a batch after a network blip, the response can show accepted: 100 while none of those events are newly stored.
  2. Generate event_id when the event happens, not on retry. A fresh UUID per retry defeats dedupe; a stable UUID per event makes retries safe.

Per-IP and per-API-key rate limits are enforced. When you exceed a limit you receive 429 with a Retry-After header (seconds). Honor Retry-After before resending.

Limits are not published numerically and may change. The SDK respects Retry-After automatically; direct callers should do the same.

The browser SDK implements the pattern below. Server callers wanting parity can mirror it:

  • Batch up to 100 events per request, or flush every ~5 seconds, whichever comes first.
  • Honor Retry-After on 429 and 5xx.
  • Exponential backoff with jitter for retries (e.g., starting at 1 s, capped around 30 s, with ±30% jitter).
  • Stop retrying a batch on 400 or 413 — the payload is malformed or too large; re-sending won’t help. Log it and drop.
  • Stop sending entirely on 401 — the key is bad or revoked; surface this to your operators.
  • Retry on 429 and 5xx with backoff.
  • Limit retry attempts (the SDK uses 5) so a sustained outage doesn’t queue events forever.
Terminal window
curl -X POST https://events.vektis.io/api/v1/events \
-H "Content-Type: application/json" \
-H "X-Vektis-Key: $VEKTIS_KEY" \
-d '{
"events": [{
"event_id": "550e8400-e29b-41d4-a716-446655440000",
"event_type": "feature.used",
"feature_id": "reports-dashboard",
"customer_id": "acct_A1",
"user_id": "user_123",
"action": "opened",
"properties": { "tab": "overview" }
}]
}'
Terminal window
curl -X POST https://events.vektis.io/api/v1/events \
-H "Content-Type: application/json" \
-H "X-Vektis-Key: $VEKTIS_KEY" \
-d '{
"events": [{
"event_id": "550e8400-e29b-41d4-a716-446655440001",
"event_type": "customer.identified",
"customer_id": "acct_A1",
"user_id": "user_123"
}]
}'

Batched (feature.used + feature.engagement)

Section titled “Batched (feature.used + feature.engagement)”
Terminal window
curl -X POST https://events.vektis.io/api/v1/events \
-H "Content-Type: application/json" \
-H "X-Vektis-Key: $VEKTIS_KEY" \
-d '{
"events": [
{
"event_id": "550e8400-e29b-41d4-a716-446655440010",
"event_type": "feature.used",
"feature_id": "reports-dashboard",
"customer_id": "acct_A1",
"user_id": "user_123",
"action": "opened"
},
{
"event_id": "550e8400-e29b-41d4-a716-446655440011",
"event_type": "feature.engagement",
"feature_id": "reports-dashboard",
"customer_id": "acct_A1",
"user_id": "user_123",
"action": "export",
"properties": { "format": "csv", "row_count": 1240 }
}
]
}'

The two examples above show the protocol. These show the instrumentation — the code that decides whether to send, and the dev item configuration it has to agree with. Both halves are shown deliberately: the mistake this page exists to prevent is the configuration and the code disagreeing.

Both examples use sendVektisEvent(...) as shorthand for a POST of a single event in the shape described under Request body.

  1. Configure the dev item.

    Open Edit dev item → “How should VEKTIS measure this?” and choose VEKTIS measures it for me. Set:

    FieldValue
    Feature IDreports-export
    Value TypeCount occurrences
    Action nameexported

    exported is the string the rest of this example has to match, character for character.

  2. Emit from the handler.

    // POST /api/reports/:id/export
    export async function exportReport(req: Request, res: Response) {
    const report = await buildExport(req.params.id);
    res.json(report);
    // Emit after the work succeeded, never before — and never awaited. The
    // response has already been sent; a slow or failing analytics POST must not
    // become this request's latency or its unhandled rejection.
    sendVektisEvent({
    event_id: crypto.randomUUID(), // generated here; reuse it if you retry
    event_type: "feature.used", // a count metric — see the Value Type table
    feature_id: "reports-export", // matches the Feature ID above
    action: "exported", // matches the Action name above
    customer_id: req.auth.accountId, // the account, not the individual user
    user_id: req.auth.userId,
    properties: {
    is_agent_call: true, // an intent endpoint, not a cron path
    row_count: report.rows.length, // the shape of the answer, never its contents
    },
    }).catch((err) => logger.warn({ err }, "vektis event dropped"));
    }
  3. The event that produces.

    {
    "events": [{
    "event_id": "550e8400-e29b-41d4-a716-446655440020",
    "event_type": "feature.used",
    "feature_id": "reports-export",
    "customer_id": "acct_A1",
    "user_id": "user_123",
    "action": "exported",
    "properties": { "is_agent_call": true, "row_count": 1240 }
    }]
    }

    "action": "exported" and "feature_id": "reports-export" are the same two strings you typed in step 1. That is the whole contract.

  4. Confirm.

    Open the dev item. No warning means both matched.

    Then prove the check works: change the Action name on the dev item to export, send another event, and the dev item will name both export and exported back to you. Change it back when you are done.

Same four steps. The difference is that you decide whether to emit before you emit.

  1. Configure the dev item.

    FieldValue
    Feature IDreport-export-tool
    Value TypeCount occurrences
    Action nameexported

    Put the tool name in Action name only if you are measuring one tool per dev item. Otherwise Action name carries the configured value and the tool name rides along in properties, as below.

  2. Wrap the handler.

    server.registerTool("export_report", schema, async (args, extra) => {
    const result = await runExport(args);
    // Condition 1 needs no code: a JSON-RPC protocol error means this handler
    // was never reached.
    const isTerminal =
    result.isError !== true && // condition 2
    (resultTypeOf(result) === "complete" ||
    resultTypeOf(result) === undefined) && // condition 3
    args.page_token === undefined; // condition 4 — THIS tool's
    // own paging argument. Name
    // yours, or confirm it has none.
    // Deliberately NOT checked: requestState / inputResponses. The leg that
    // carries them is the one that succeeded — excluding on them would score
    // every prompt-for-input tool zero.
    const customerId = customerIdFrom(extra);
    if (isTerminal && customerId) {
    // Not awaited: the customer's tool call must not wait on, or fail with,
    // an analytics POST.
    sendVektisEvent({
    event_id: crypto.randomUUID(),
    event_type: "feature.used",
    feature_id: "report-export-tool", // matches the Feature ID above
    action: "exported", // matches the Action name above
    customer_id: customerId,
    properties: {
    is_agent_call: true, // true by construction inside a tool handler
    tool: "export_report", // the tool name, since the metric isn't per-tool
    },
    }).catch(() => {}); // never let telemetry break the tool
    }
    return result;
    });
    // `resultType` is spec 2026-07-28 and not yet in the SDK's CallToolResult type,
    // so read it off the value rather than the declared shape. Absent means a server
    // on an older revision — treated as complete, per condition 3.
    function resultTypeOf(result: object): string | undefined {
    const value = (result as { resultType?: unknown }).resultType;
    return typeof value === "string" ? value : undefined;
    }
    function customerIdFrom(extra: { authInfo?: AuthInfo }): string | null {
    // `authInfo.extra` is where your token verifier puts the validated claims —
    // AuthInfo itself carries only token/clientId/scopes. For an opaque token,
    // populate it from introspection; never parse the token here.
    const sub = extra.authInfo?.extra?.["sub"];
    return (
    (typeof sub === "string" ? sub : undefined) ?? // authenticated remote MCP
    process.env.VEKTIS_CUSTOMER_ID ?? // stdio / unauthenticated remote
    null // neither: emit nothing. Never substitute a constant or a
    ); // generated id.
    }
  3. The event that produces.

    {
    "events": [{
    "event_id": "550e8400-e29b-41d4-a716-446655440021",
    "event_type": "feature.used",
    "feature_id": "report-export-tool",
    "customer_id": "acct_A1",
    "action": "exported",
    "properties": { "is_agent_call": true, "tool": "export_report" }
    }]
    }
  4. Confirm.

    Same as above — open the dev item and expect no warning.

Here is a realistic turn against that tool. One person asked one question:

#What the model didResultEmitted?
1Called the tool with a malformed dateisError: trueNo — condition 2
2Retried; the tool needs a missing fieldresultType: "input_required"No — condition 3
3Supplied the field; the call completesresultType: "complete"Yes — one event
4Called again with page_token for page 2completeNo — condition 4
5Called a tool name that does not existJSON-RPC errorNo — condition 1

One question, five tool calls, one event.

Without the guards this turn contributes four — and the same question asked of a chattier model contributes a different number again, so the score would move without usage moving. That is the inflation the conditions exist to stop.

Row 3 is also the reason inputResponses must not exclude: it is the leg carrying that field, and it is the only leg that succeeded. Guard on it and this entire turn scores zero.