---
title: "Agent loop"
description: "Record spans and turns from your own agent as it runs."
---

This is the **observability** install: your product already calls a model. Assay records each unit of work beside that call. Labelling is optional.

You need Node ≥ 20 and the Assay package. The collector binary is the capture path; this page is the library path.

## Record spend

```ts
import { createMeter, SqliteSpanStore } from 'assay';

const store = new SqliteSpanStore('./assay.sqlite');
const meter = createMeter({ store });

const { span, created } = await meter.record({
  producerRef: 'gateway:sess-1:1', // idempotency key — re-delivery is a no-op
  principal: 'acct_42',
  sessionId: 'sess-1',
  taskId: 'turn-1',
  provider: 'anthropic',
  model: 'claude-sonnet-4-6',
  lane: 'frontier',
  tokens: { input: 1200, output: 800 },
});
```

`created` is `false` when the same `producerRef` already exists. That is the exactly-once signal a wallet debit can key on.

## Record the conversation

```ts
import { createTranscriptRecorder, SqliteTranscriptStore } from 'assay/transcripts';

const transcripts = new SqliteTranscriptStore('./assay.sqlite');
const recorder = createTranscriptRecorder({
  store: transcripts,
  principalKeys: transcripts,
});

await recorder.recordTurn({
  producerRef: 'gateway:sess-1:1:msg',
  principal: 'acct_42',
  sessionId: 'sess-1',
  kind: 'message',
  role: 'human',
  content: userText,
  spanRef: 'gateway:sess-1:1',
});
```

Pass principal keys so one user can later be shredded without sealing everyone under one key.

Join `spanRef` to the `producerRef` you used on `meter.record`. That is how [Debug console](/debug) prints cost next to the turn.

## Declare what the sitting was for

A producer that **knows** the job should say so, instead of waiting for a guess:

```ts
await recorder.declareSession({
  sessionId: 'sess-1',
  principal: 'acct_42',
  goal: 'build:feature',
  vocabulary: 'yourapp:goal-vocabulary@1',
});
```

Assay admits or refuses the label. It does not rewrite it into a near miss. Register your own closed vocabulary. Assay ships the contract, not your taxonomy.

Rank is **declared > judge > heuristic**. A store with no declarations behaves as before.

## Postgres

Assay does not depend on `pg`. You inject a pool and migrate explicitly:

```ts
import { Pool } from 'pg';
import { applyAssayMigrations, createMeter, PostgresSpanStore } from 'assay';

const pool = new Pool({ connectionString: process.env.DATABASE_URL });
await applyAssayMigrations(pool);
const meter = createMeter({ store: new PostgresSpanStore(pool) });
```

## Language-agnostic alternative

If the loop is not Node, export OpenTelemetry `gen_ai` traces to [`assay serve`](/ingest).

## Read it back

```bash
assay turns --session sess-1 --json
assay dashboard
```

To put the same panels inside your app: [Embed](/embed).
