Skip to content
VeritioDocs

Agent events

Kind
tutorial
For
developer · governance
Verified against
@veritio/core@0.4.7 · provenance APIs at veritio@c4100ee

Agent evidence should answer who initiated a session, what class of action occurred, which artifacts it affected, and which review or deployment followed—without turning prompts, tool arguments, diffs, or secrets into a second sensitive log.

Terminal window
bun add @veritio/core@0.4.7 @veritio/storage@0.4.7

The checked fixture persists one session and one tool call into the durable file store. It supplies only stable identifiers and one-way hashes.

src/examples/agents/session-and-tool.ts
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createProvenanceRecorder } from '@veritio/core'
import { createFileEvidenceStore } from '@veritio/storage'
/**
* Records one agent session and one minimized tool call, then verifies the
* durable event and edge chains without ever supplying raw prompt text.
*/
export async function recordAgentSession() {
const directory = await mkdtemp(join(tmpdir(), 'veritio-agent-session-'))
try {
const store = createFileEvidenceStore(directory)
const recorder = createProvenanceRecorder(store)
const { session } = await recorder.startSession({
sessionId: 'session_review_01',
occurredAt: '2026-08-09T13:00:00.000Z',
scope: { tenantId: 'org_acme', environment: 'test' },
initiatedBy: { type: 'user', id: 'usr_reviewer' },
agentActor: { type: 'ai_agent', id: 'agent_coding_01' },
agent: { name: 'coding-agent', version: '1.0.0' },
model: { provider: 'example', name: 'review-model' },
promptHash: 'sha256:prompt-content-hash',
repository: { provider: 'github', id: 'repo_public_id' },
})
await session.recordToolCall({
toolCallId: 'tool_read_01',
occurredAt: '2026-08-09T13:00:01.000Z',
tool: 'read_file',
status: 'succeeded',
inputHash: 'sha256:tool-input-hash',
reads: [{ id: 'file_auth_service', pathHash: 'sha256:path-hash' }],
})
const events = await store.listEvents()
const edges = await store.listEdges()
const serialized = JSON.stringify({ events, edges })
return {
eventActions: events.map((record) => record.event.action),
edgeRelations: edges.map((record) => record.edge.relation),
rawPromptStored: serialized.includes('the raw prompt'),
verification: await store.verify(),
}
} finally {
await rm(directory, { recursive: true })
}
}
if (import.meta.main) {
console.log(JSON.stringify(await recordAgentSession(), null, 2))
}
Terminal window
bun src/examples/agents/session-and-tool.ts

The build executes that command and compares stdout byte-for-byte:

verified output
{
"eventActions": [
"agent.session.started",
"agent.tool.called"
],
"edgeRelations": [
"caused_by",
"created",
"read"
],
"rawPromptStored": false,
"verification": {
"ok": true,
"audit": {
"ok": true
},
"edges": {
"ok": true
},
"commits": {
"ok": true
}
}
}

The session event is linked to the enforcing human with caused_by. The session creation and tool read produce graph edges separately from event metadata. rawPromptStored remains false because the recorder never receives prompt text.

Action strings are literal. They are not glob families, and no wildcard is expanded anywhere in the SDK — a reader that filters on the wrong string finds nothing rather than failing loudly. These are the strings the shipped recorder writes.

Call Action emitted Target entity type
startSession agent.session.started agent_session
recordPrompt agent.prompt.recorded agent_session
recordToolCall agent.tool.called tool_call
recordChangeProposal change.proposal.created change_proposal
recordFileChange change.files.changed source_tree
recordReview review.approval.recorded, review.finding.created, or review.waiver.recorded pull_request
recordCiRun ci.job.completed ci_run
recordDeployment deploy.deployed deployment
recordRuntimeEvent caller-supplied action runtime_event

Three details in that table are easy to get wrong.

change.files.changed is plural. The event targets a source tree at one resulting version and carries a files array, so the noun is plural even when a single file moved.

recordReview branches on decision. One call site emits three different actions: approved (or an omitted decision) produces review.approval.recorded with an approved_by edge, changes_requested produces review.finding.created with a reviewed_by edge, and waived produces review.waiver.recorded with a waived_by edge. The relation never asserts approval of work that was not approved.

recordRuntimeEvent does not own an action string. The host passes action; the recorder writes it verbatim onto a runtime_event target. The canonical string for that shape is audit.runtime.observed, and it lives in auditTemplates.code.runtimeObserved rather than in the recorder. If you want the catalogued value, pass it explicitly.

One further action belongs to this family but is never emitted by a session: activity.episode.started, built by episodeStartedTemplate. It opens the episode described in activity episodes; the recorder joins an existing episode rather than opening one.

The full per-method edge list, deterministic id formulas, and idempotency-key collisions are in the provenance recorder reference.

The events above land on unrelated targets. A source_tree, a pull_request, a ci_run, and a deployment are shared entities that many sessions touch, so the edge graph alone cannot tell you which session produced a given file change. The recorder solves this with two metadata stamps rather than new schema fields:

agent.session.started → target agent_session
agent.prompt.recorded → target agent_session
agent.tool.called → target tool_call
change.files.changed → target source_tree (shared)
review.approval.recorded → target pull_request (shared)
ci.job.completed → target ci_run (shared)
deploy.deployed → target deployment (shared)
every one of them carries
metadata.sessionId = <sessionId>
metadata.activityEpisodeId = <activityEpisodeId>

Both keys are applied after the caller’s metadata is spread in, so a caller cannot shadow either one. Both land before createAuditEvent canonicalizes and hashes the record, so the attribution sits inside the hash chain rather than beside it. Neither key matches the redaction key pattern, and both are non-PII stable ids by construction, so redaction leaves them intact.

The second checked fixture drives a full seven-event session and deliberately passes forged sessionId and activityEpisodeId values into startSession and recordPrompt:

verified output
{
"eventActions": [
"agent.session.started",
"agent.prompt.recorded",
"agent.tool.called",
"change.files.changed",
"review.approval.recorded",
"ci.job.completed",
"deploy.deployed"
],
"eventIds": [
"evt_session__session_release_42",
"evt_prompt__session_release_42__sha256:prompt-content-hash__2026-08-09T13:00:05.000Z",
"evt_tool__tool_edit_01",
"evt_filechange__tree_main__42",
"evt_review__pr_1042",
"evt_ci__ci_run_9001",
"evt_deploy__deploy_42"
],
"attribution": {
"sessionId": "session_release_42",
"activityEpisodeId": "episode_release_42",
"eventCount": 7,
"everyEventCarriesSessionId": true,
"everyEventCarriesActivityEpisodeId": true,
"distinctSessionIds": [
"session_release_42"
],
"distinctActivityEpisodeIds": [
"episode_release_42"
]
},
"callerCannotShadow": {
"suppliedSessionId": "session_forged_by_caller",
"suppliedActivityEpisodeId": "episode_forged_by_caller",
"recordedOnSessionEvent": {
"sessionId": "session_release_42",
"activityEpisodeId": "episode_release_42"
},
"recordedOnPromptEvent": {
"sessionId": "session_release_42",
"activityEpisodeId": "episode_release_42"
},
"forgedIdsPresentAnywhere": false,
"callerMetadataPreserved": true
},
"edgeRelations": [
"caused_by",
"created",
"read",
"modified",
"modified",
"approved_by",
"built_by",
"derived_from",
"deployed_as",
"satisfies_policy"
],
"verification": {
"ok": true,
"audit": {
"ok": true
},
"edges": {
"ok": true
},
"commits": {
"ok": true
}
}
}

Read that output as four separate claims. eventActions prints the literal strings from the table above, in recording order. attribution shows all seven events resolving to exactly one session id and one episode id — the distinct* arrays are single-element, which is the assertion a group-by read model depends on. callerCannotShadow shows the forged ids absent from the persisted event and edge chains entirely, while unrelated caller metadata (turnBudget) survives untouched. verification.ok covers the event chain, the edge chain, and the commit ledger after all writes.

Keep:

  • stable session, turn, tool-call, artifact, review, and deployment IDs;
  • content, path, configuration, policy, and bundle hashes;
  • bounded status, outcome, latency, and approval labels;
  • tenant, workspace, and environment scope resolved by the host.

Omit:

  • raw prompts and model responses;
  • tool arguments, command output, file contents, and diffs;
  • access tokens, cookies, connection strings, and arbitrary local paths;
  • names or email addresses when a stable non-PII actor ID is sufficient.

The recorder does not enforce this. Principal.display is not redacted at all, and metadata values are never inspected — only key names are. Minimization is the host’s job, upstream of the first call.

Each recorder method writes its event first and then its connecting edges, sequentially. The recorder does not create a cross-sink transaction and performs no compensation, so an edge-sink failure can leave a committed event with missing edges. If an event and its edges must commit atomically, inject sinks backed by the same host transaction or persist a batch/commit through a store that implements that boundary.

recordPrompt is the one call that emits no edges at all: the prompt is already attributed to its session by target and by metadata.sessionId, so no edge would add information.

Re-recording the same logical event yields the same deterministic id, which is what makes replay safe — and also what makes two genuinely different occurrences collide. Two file changes to the same sourceTreeId at the same resultVersion derive one id and the second write is rejected on the idempotency key. Pass distinct result versions, or override id.

Prompt ids fold in occurredAt when supplied, precisely because the same prompt text submitted twice in one session would otherwise collide. Omit occurredAt and identical prompts become one record.

Before enabling live capture, assert against fixtures that:

  1. prohibited raw fields never reach either sink;
  2. repeated delivery creates the same record and edge IDs;
  3. an edge write failure is visible and recoverable;
  4. tenant scope cannot be supplied by the agent payload;
  5. every session-produced event carries the expected sessionId and activityEpisodeId;
  6. event, edge, and commit chains all verify after restart.

A green result here is evidence about your capture path, not a statement about your legal position. Veritio supports compliance evidence; it does not determine whether an obligation was met.

Use the packaged Claude Code hooks or the experimental source-backed Codex notify adapter next.