Skip to main content
This page wraps a model instead of an application. You add one middleware, and every generateText and streamText call through that model becomes its own signed record. The middleware needs an anchorer, which Anchor from your agent builds and explains.

Install the adapter

@sanning/anchor-vercel takes the Vercel ai package as a peer dependency.

Wrap the model

Prompts, settings and outputs are hashed in this process. The signed envelope carries only the hash, so the bytes never reach Sanning. What each event committed stays with you, in the logStore you configured, or in step.result.eventBytes if you drain it yourself. Each event is one signed envelope sent to Sanning, and nothing comes back: no receipt and no transaction id. Sanning witnesses envelopes on an interval, and the proof travels in the pack you build at hand-over. Enrollment is automatic before the first anchor, so there is no enrollment step to write. A Live API key refuses auto-generated secrets: createAnchorer throws unless you pass a signer and a subject. Sandbox records are permanently marked environment: "dev" inside the signed bytes, so a Sandbox record can never be presented as Live evidence. See Sandbox and live.

Group calls into a chain

The middleware wraps the model, not the application, so unlike Trace a LangChain agent, whose callbacks give a run tree, events here form a flat chain (run.seq plus run.prev_event_id, committed in each record’s metadata). You choose what groups them:
  • Correlation id, the choice for multi-call requests. Pass an id through providerOptions.sanning.chainKey and every call sharing it links in order: your request id, conversation id, or agent-loop id.
  • Session fallback, zero config. With no id, all calls through one middleware instance link in emission order under a per-instance session:<uuid> chain.
The same key names the record chain the anchorer keeps: each record’s previous_hash is the previous record’s hash, set before the envelope is sent. An event that never arrived leaves a pointer that dangles and a hole in run.seq, both visible offline. Pass chain: false to anchorMiddleware to turn the record chain off; run.seq is still committed.
A chainKey must have exactly one writer: one anchorer instance, for as long as the key is in use. Two writers on one key start from the same head, which gives one chain two branches that both verify. This happens on a serverless or multi-instance deployment handling one conversation id on several instances, on a process restart that reuses a stable chainKey over the default in-memory store, or when several instances share one durable store with no compare-and-set.A stable chainKey is safe only when one long-lived process owns it, with a durable store so a restart continues the chain. Where you cannot promise one writer, leave chainKey unset and use the session fallback, which is a fresh id per middleware instance and is not affected.

Event vocabulary

vercel_ai.generate_start, _end and _error, plus vercel_ai.stream_start, _end and _error: one event type per anchored operation, plus vercel_ai.run_closed, the closing record, and vercel_ai.artifact, a file a tool read or wrote. All eight are exported as EVENT_TYPES. Each operation gets exactly one terminal event, _end on success and _error on failure. For streams, a provider failure arrives in-band as an error part and anchors stream_error, while chunks pass through untouched. A hard transport abort, where the stream rejects with no error part, anchors neither terminal event: the stream_start stands, and its missing completion is itself the signal, because its chain pointer dangles.

What the hash commits to

The whole step, nothing curated. Only the hash reaches Sanning; the bytes go to your own store.
  • model, the model surface the middleware sees: specificationVersion, provider, modelId.
  • params, the call options in full: prompt, temperature, topP and topK, penalties, stopSequences, maxOutputTokens, seed, responseFormat, the tools bound, toolChoice, headers and providerOptions.
  • the result, the provider response in full: content, usage, finish reason, provider metadata, the request and response envelopes, warnings.
Credential-shaped values are scrubbed automatically, which matters here because params.headers is where a bearer token actually lives, and a committed record cannot be redacted afterwards. For a stream, the terminal event commits every non-delta part: the stream-start warnings, the response-metadata, the whole finish part, plus the part count and the request and response envelopes. Content deltas are not transcribed: they are your own output, arriving in your hands chunk by chunk, and re-committing them here would carry a second copy of every streamed call’s transcript. Anchor that content yourself if you want it hashed. onEvent lets you watch what is committed. It cannot change it:

The promoted fields

A closed list is copied into named metadata alongside the body, so a reader can select one call, or one model, without opening it:
  • The run linkage: run.run_id (this call’s own id), run.parent_run_id (always null: this seam has no run tree), run.root_run_id (the resolved chain key), run.seq and run.prev_event_id, plus run.client (the AI SDK provider id).
  • The recorder: otel.scope.name (@sanning/anchor-vercel), otel.scope.version and otel.scope.schema_url (https://opentelemetry.io/schemas/1.41.0), on every record. otel.trace_id and otel.span_id appear when a real OpenTelemetry span exists.
  • Model facts, under metadata.gen_ai with the names the OpenTelemetry GenAI semantic conventions give them, pinned to v1.41.0: gen_ai.provider.name, gen_ai.request.model, the call’s settings (gen_ai.request.temperature, .max_tokens, .seed, .top_p), gen_ai.response.model (the model the provider reports answered, where it reports one), gen_ai.response.id, gen_ai.response.finish_reasons and token use (gen_ai.usage.input_tokens, .output_tokens, .reasoning.output_tokens). On a generate call whose OpenAI-compatible response carries one, openai.response.system_fingerprint.
  • Your own claims, when you give them: gen_ai.agent.version from the agentVersion option, on every record, and gen_ai.prompt.name from providerOptions.sanning.promptName on the call.
This seam sees no step or tool names, so run.name, gen_ai.tool.name and gen_ai.tool.call.id never appear here. These fields are what the integration reported, not proof of what served the request. gen_ai.provider.name is the service that served the call, taken from the endpoint the wrapped model’s own config exposes, never from the AI SDK provider id alone. So createOpenAI({ baseURL: "https://openrouter.ai/api/v1" }) records openrouter. The field is absent when the model exposes no endpoint, or when the endpoint’s host is one the adapter does not know, such as a gateway or a proxy. A field that is unknown is absent, never empty, with the two exceptions above that carry an explicit null. Whether a chain key was your own or the adapter’s session fallback is recorded separately, in its own namespace: vercel_ai.caller_supplied_chain.

Close the middleware and read the gaps

  • steps are the events that were delivered, each with its AnchorResult.
  • gaps are events whose chain slot was spent and whose envelope did not arrive. A gap is reported undelivered, never as a bad record: a timeout says nothing about whether Sanning accepted it. Its eventBytes are retained, and after a failure they are the only copy of something that may already be committed.
  • unbuilt are events that never reached the anchorer, such as a payload that fails to serialize or a recordFile() call made during or after close(). They have no record and no bytes to retain.
A middleware answers for its own records. gaps, isComplete, assertComplete() and close() count only the records that middleware wrote. close() never closes the anchorer: call await anchorer.close() when the process is done with it, which is where a buffered record still pending is reported. anchorer.gaps covers every hole on the anchorer. close() does not throw by default, because it runs in a finally block and a throw there would replace the request’s real error. Pass { raiseOnGaps: true } to make an incomplete trail an error, or call evidence.assertComplete() on the success path. Provenance never crashes the call: a failure to anchor is reported through a warning and recorded in unbuilt, and the model call still runs and returns. Errors from the model are anchored as _error events and re-thrown to your code unchanged.

Show which policy a step applied

The middleware sees model calls, not tool executions: a tool the model asks for runs in your code, outside the middleware. To show which policy file a step read, have the tool hand the file to recordFile() from its execute, naming the tool call it ran under. The record commits to the file’s bytes.
recordFile() writes one vercel_ai.artifact record on the same chain key as the model calls around it. The chain key’s vercel_ai.run_closed record counts it, so a pack that leaves it out shows a hole. Leave chainKey out when your calls use the session fallback. Record the file through the middleware, not with anchorer.anchor() beside it: a record anchored on its own sits outside the chain and the closing count, so a pack without it shows nothing missing. recordFile() copies the bytes when you call it, so the record commits to the file as it was then and the tool can reuse or zero its buffer afterwards. It also takes these optional fields:
  • role: "input" for a file the tool read, "output" for one it wrote. Absent unless you set it.
  • source: where an input file came from, in your own words.
  • destination: where an output file went, in your own words.
All three land unchanged on the record’s metadata.artifact. A recordFile() call during or after close() cannot join the chain: it lands on unbuilt, and isComplete is false. An auditor holding the policy runs sha256sum and compares the result with the record’s event.content_hash. With a logStore, the file’s bytes go there and never to Sanning.

Keep what you anchored

The store the hand-over pack reads, and what breaks without it.

Trace a LangChain agent

The same anchorer, wired to a run tree instead of one model.