Quick start
Two lines to record it. One link to check it. From an API key to a committed entry and a link someone else can check, in five steps.
Two lines to record it. One link to check it.
This page records one entry, a model build that passed its tests, and hands someone else a link to check it. The same two calls record what an agent did; What you can record shows the cases.
Before you start
You need a records workspace. Request access at app.pacspace.io; when your access is approved, pick the records option as you register, or ask us to switch a workspace you already have. A new workspace writes once its recording environment is ready, and the dashboard shows when it is. Everything below runs against https://app.pacspace.io.
Step 1. Install
npm install @pacspace-io/sdkThe TypeScript SDK needs Node.js 20.19 or later.
Create a key under Settings, Developer, API keys and put it in PACSPACE_API_KEY. A pk_test_ key writes to your Sandbox and a pk_live_ key to Production, on the same host. The key decides, so nothing else is set.
Step 2. Fingerprint the file
Fingerprint the build where it is kept. The bytes never leave; PacSpace receives the fingerprint and the size. A fingerprint is blinded by default, so fingerprinting the same file twice gives two different fingerprints. The helper below keeps the build's blinding file beside it and uses it every time after, so a retry sends the same fingerprint.
import { createReadStream } from 'node:fs';
import { fingerprint, readBlindingFile, writeBlindingFile } from '@pacspace-io/sdk';
// Fingerprint a file where it is kept, and keep its blinding file beside it.
// A second call for the same file reuses that blinding file, so it gives the same fingerprint.
async function refOf(path: string) {
const kept = await readBlindingFile(`${path}.pacspace.json`).catch(() => undefined);
const { ref, blinding } = await fingerprint(createReadStream(path), { blinding: kept });
if (!kept && blinding) await writeBlindingFile(path, blinding);
return ref;
}
const ref = await refOf('build-2026.09.3.tar');ref holds the fingerprint, the method, and the size. Of the build, it is all the entry carries:
{ "alg": "hmac-sha-256", "digest": "<64 hex characters>", "byteLength": "734003200" }The blinding file, build-2026.09.3.tar.pacspace.json, stays beside the build. Keep it as private as the build: whoever holds both can confirm the build is in the record. fingerprint also takes bytes you hold in memory or a stream; see Fingerprint a file.
Step 3. Write the entry
import { PacSpace } from '@pacspace-io/sdk';
const pac = new PacSpace({ apiKey: process.env.PACSPACE_API_KEY! });
const entry = await pac.records.emit({
record: 'build-2026.09.3',
title: 'Build 2026.09.3 passed 412 of 412 tests',
kind: 'test-completed',
occurredAt: '2026-09-09T17:40:00Z',
actorId: 'safety-evals',
payloads: [ref],
idempotencyKey: 'build-2026.09.3:tested',
});record is your id for the record; every later entry about this build uses the same one. title is what a person reads, and the first entry's title is also the record's name. kind is your own word for the sort of thing this is, in lower case with hyphens. occurredAt is when it happened; the time of your call is recorded on its own, as receivedAt in the answer. actorId is who or what did it. idempotencyKey makes a retry safe: one key is one write, so use a new key for every entry and reuse one only to send the same entry again.
The answer comes back at once, with status set to QUEUED: PacSpace has the entry, and committing comes next. It also carries recordKey, the record's fixed key, which another record uses to name this one. Write an entry has every field and the whole answer.
Step 4. Wait for committed
Committed means the entry is in the record and anyone you give the link to can check it, with PacSpace out of the loop. Once committed, no one can change this record, PacSpace included.
Read the record's history until the entry shows committed. Each entry carries a status, queued, committed, or failed, and a failed entry carries a code and a plain sentence.
const { entries } = await pac.records.history({ record: 'build-2026.09.3' });
entries?.[0]?.status; // 'queued', then 'committed'To hear about it without polling, add an endpoint under Settings, Developer, Webhooks. PacSpace sends record.committed when the entry commits, and record.failed when a queued entry could not be committed. Check the signature with the endpoint's secret before you act on the body:
import { Webhooks, type RecordCommittedPayload } from '@pacspace-io/sdk';
const webhooks = new Webhooks(process.env.PACSPACE_WEBHOOK_SECRET!);
// In your endpoint's handler. rawBody is the request body as received, before any JSON parsing.
const event = webhooks.verifyFromHeaders(headers, rawBody); // throws WebhookVerificationError on a bad signature
if (event.event === 'record.committed') {
for (const r of (event.data as RecordCommittedPayload).records) {
// r.entityId is the record, build-2026.09.3; r.seq + 1 is the entry number
}
}The webhook names the entry by seq, its position on the wire, counted from 0. This first entry arrives as seq: 0: Entry 1 on every page, and entry: 1 in the SDKs. Payload reference has the whole body, and Signature verification shows how to keep the raw body in your framework.
Step 5. Check it and hand the link
Check the record from your own code. expect hands the check your fingerprint from Step 2, so the result also says whether the build you hold is the one that was committed.
const history = await pac.records.history({ record: 'build-2026.09.3' });
const result = await pac.records.check({ history, expect: [{ entry: 1, payloads: [ref] }] }); // entries count from 1
result.ok; // true when every check passed
result.entries[0].expected; // true when your fingerprint is among entry 1's committed onesThe TypeScript check runs in your process over every committed entry's seal, their order, and the history file's own fingerprint. The Python check compares your fingerprint with what was committed and reads each entry's status; it does not check the seals. For the whole check from either language, save the history file and run the open-source checker, which runs without PacSpace. See Check.
curl -H "x-api-key: $PACSPACE_API_KEY" -H "Accept: application/octet-stream" -o history.json \
https://app.pacspace.io/api/v1/records/machine-action-record/build-2026.09.3/history
npx @pacspace-io/check history.jsonThen hand the link.
curl -X POST -H "x-api-key: $PACSPACE_API_KEY" \
https://app.pacspace.io/api/v1/records/machine-action-record/build-2026.09.3/shareThe answer carries the link in url, in the form https://app.pacspace.io/c/csh1_..., and a 6-character code in accessCode. Send the link to whoever has to rely on the record, and send the code by a separate channel, such as a call. The link asks for the code and shows nothing of the record until it is entered. Then the record opens and their browser runs the check at once.
A link made with this call shows each entry's seal and none of its fields. To show the fields, share from the record page in the dashboard and choose them there. The link opens for 30 days. Share a record covers the expiry, who the link is for, a new link, and turning one off.
If the call is refused
On the wire each code is at error.code in the answer's body. In the SDKs a refusal arrives as a PacSpaceError whose code is the code below, unless the line names the SDK's own class.
- 401, with no code. A key is one your workspace lists under Settings, Developer, API keys, sent as
x-api-key. Check thatPACSPACE_API_KEYis set in this shell and that the key has not been disabled. The SDKs raiseInvalidApiKeyError. RECORDS_API_NOT_ENABLED(403). Records are written from a records workspace. Pick the records option at signup, or ask us to switch the workspace.RECORDS_CONTRACT_NOT_READY(412). A workspace writes once its recording environment is ready. Wait for the dashboard to show ready, then send again. The SDKs raiseContractNotDeployedError.RECORD_CONTENT_INVALID(400). An entry carries the fields the record type names, each in its declared form. Fix the field the message names and send again.RECORD_PAYLOADS_REQUIRED(422). Every entry carries at least one fingerprint. Put thereffrom Step 2 inpayloads.RECORD_TITLE_TOO_LONG(422). A title fits in 120 characters. Shorten it; the rest belongs indescription.RECORD_BLINDING_INCLUDED(400). A payload carries the fingerprint'srefonly, and the blinding file stays with you. Put thereffrom Step 2 inpayloadsand keep the blinding beside the file.IDEMPOTENCY_CONFLICT(409). One key is one write, and this key was first used with a different body. A retry that fingerprints the file afresh sends a different fingerprint; the helper in Step 2 reuses the blinding file so it does not. A new entry takes a new key.IDEMPOTENCY_PENDING(202). The first write with this key is still running. WaitretryAfterSeconds, then send the same request again. The SDKs hand this back as the write's answer, withcodeandretryAfterSeconds.
Every code, with what each SDK raises, is in Write an entry.
Bring the case you think breaks it
We would rather be evaluated by use than by description. Talk to us and we'll put you in a live environment: commit a record, do your best to change it, then check it yourself, with us out of the loop. The change shows.