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. Production refuses auto-generated secrets: createEnvelopeAnchorer
throws unless you pass a signer and a subject. Dev records are permanently
marked environment: "dev" inside the signed bytes, so a dev record can never
be presented as production evidence.
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 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, 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: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,
run.prev_event_id, model.id, model.id_returned (the model the
provider reports answered, where it reports one), model.provider, and
otel.trace_id / otel.span_id when a real OpenTelemetry span exists.
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. They have no record and no bytes to retain.
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.
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.