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
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.chainKeyand 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.
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.
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,topPandtopK, penalties,stopSequences,maxOutputTokens,seed,responseFormat, the tools bound,toolChoice, headers andproviderOptions.- the result, the provider response in full: content, usage, finish reason, provider metadata, the request and response envelopes, warnings.
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(alwaysnull: this seam has no run tree),run.root_run_id(the resolved chain key),run.seqandrun.prev_event_id, plusrun.client(the AI SDK provider id). - The recorder:
otel.scope.name(@sanning/anchor-vercel),otel.scope.versionandotel.scope.schema_url(https://opentelemetry.io/schemas/1.41.0), on every record.otel.trace_idandotel.span_idappear when a real OpenTelemetry span exists. - Model facts, under
metadata.gen_aiwith 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_reasonsand 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.versionfrom theagentVersionoption, on every record, andgen_ai.prompt.namefromproviderOptions.sanning.promptNameon the call.
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
stepsare the events that were delivered, each with itsAnchorResult.gapsare events whose chain slot was spent and whose envelope did not arrive. A gap is reportedundelivered, never as a bad record: a timeout says nothing about whether Sanning accepted it. ItseventBytesare retained, and after a failure they are the only copy of something that may already be committed.unbuiltare events that never reached the anchorer, such as a payload that fails to serialize or arecordFile()call made during or afterclose(). They have no record and no bytes to retain.
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 torecordFile() 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.
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.
What to read next
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.