Define custom Types
A Server ships with no Types of its own. Before an agent can notarize anything, someone registers a
Type — the shape a record must match. The only Type a fresh Server carries is
notarize-generic-v1, one editable example you can keep, rename, or delete like any other. Real
deployments define their own.
A Type is a JSON Schema. You are not configuring a product feature; you are declaring, in the same JSON Schema vocabulary Fastify and every validator already speak, what each record of this Type must carry. Registering it is a data operation — no redeploy, no code.
This page uses an admin key (schema registration needs the schemas:write scope; an agent key
cannot register a Type). See authentication for how to mint one. Set your two
inputs:
export AGLEDGER_URL=https://your-server.example
export AGLEDGER_ADMIN_KEY=agl_adm_… # an admin key with schemas:write
The one decision: notarize-only or completion-bearing
Every Type is one of two kinds, and the choice decides the record's entire lifecycle. The shape that
makes the choice is whether you supply a completionSchema.
- Notarize-only (no
completionSchema, or{}). A record terminalizes atRECORDEDthe moment it is created. There is no later phase, no verdict. This is the spine — "an agent did a thing, give me a tamper-evident record" — and it is the right choice for the large majority of work. - Completion-bearing (a structured
completionSchema). A record stays open after creation, awaiting a completion the performer submits; the engine can then render an automated accept/reject against the criteria, settling the record to FULFILLED or FAILED. This serves procurement, finance, and compliance flows where a principal needs a deterministic accept/reject — the gate, a real feature, but the exception, not the spine.
Nothing in the request body announces which kind you are creating; it falls out of whether
completionSchema is present. Start notarize-only. Reach for a completion phase only when a
principal genuinely needs to accept or reject a measurable deliverable.
Author a Type
The workflow is the same every time: start from a template, validate with a dry run, then register.
1. Start from a template
The Server hands you a skeleton so you do not start from a blank file. Ask for the notarize-only
skeleton, or pass ?withCompletion=true for the judgment-mode one.
curl -s -H "Authorization: Bearer $AGLEDGER_ADMIN_KEY" "$AGLEDGER_URL/v1/schemas/_blank"
The template it returns is a ready-to-edit Type with a recordSchema, an empty completionSchema
(notarize-only), and an empty fieldMappings. Replace the placeholder fields with your own.
2. Preview — a dry run that does not persist
Before you commit a Type to the chain, validate it. POST /v1/schemas/preview runs the exact
registration validation and tells you what it compiled, but writes nothing.
curl -s -X POST "$AGLEDGER_URL/v1/schemas/preview" \
-H "Authorization: Bearer $AGLEDGER_ADMIN_KEY" -H "Content-Type: application/json" \
-d '{
"type": "report.published.v1",
"displayName": "Report Published",
"description": "An agent notarizes that it published a report.",
"recordSchema": {
"type": "object",
"required": ["report_id", "title"],
"properties": {
"report_id": { "type": "string", "minLength": 1 },
"title": { "type": "string", "minLength": 1 },
"url": { "type": "string", "format": "uri" }
},
"additionalProperties": false
},
"completionSchema": {}
}'
{
"valid": true,
"compiled": {
"recordProperties": ["report_id", "title", "url"],
"recordRequired": ["report_id", "title"],
"completionProperties": [],
"fieldMappingCount": 0,
"estimatedVersion": 1
}
}
valid: true and an empty completionProperties confirm a notarize-only Type that would register as
version 1. A list of types unchanged after this call confirms the dry run persisted nothing.
3. Register
Send the same body to POST /v1/schemas. This persists the Type and is the only step that changes
the chain.
curl -s -X POST "$AGLEDGER_URL/v1/schemas" \
-H "Authorization: Bearer $AGLEDGER_ADMIN_KEY" -H "Content-Type: application/json" \
-d '{ "type": "report.published.v1", "displayName": "Report Published",
"description": "An agent notarizes that it published a report.",
"recordSchema": { "type": "object", "required": ["report_id","title"],
"properties": { "report_id": {"type":"string","minLength":1},
"title": {"type":"string","minLength":1}, "url": {"type":"string","format":"uri"} },
"additionalProperties": false },
"completionSchema": {} }'
The response carries the registered Type with its defaults filled in:
{
"type": "report.published.v1",
"version": 1,
"status": "ACTIVE",
"publisher": "local",
"compatibilityMode": "backward",
"manifestDigest": "sha256:ea699ba923f85e…",
"quickStart": { "criteria": { "report_id": "TODO", "title": "TODO" }, "evidence": null }
}
Three defaults worth knowing: status is ACTIVE (discoverable and accepting records immediately),
compatibilityMode is backward (new versions must not break old records — see Evolve, below), and
publisher is local (private to this Server — see Share, below). The quickStart is an
auto-derived, paste-runnable example of the criteria a record of this Type needs.
What makes a schema valid
The engine validates your recordSchema and completionSchema against a meta-schema — guardrails
that keep Types safe to store, validate, and share. Rather than memorize them, fetch them:
curl -s -H "Authorization: Bearer $AGLEDGER_ADMIN_KEY" "$AGLEDGER_URL/v1/schemas/meta-schema"
It returns the live constraints — among them: the root must be an object with a required list;
maximum nesting depth and node count; the allowed format values (date-time, email, uuid,
uri, and a few more); and a set of blocked keywords. Conditional keywords (if / then / else),
$data, $async, and custom $id are rejected, because a record schema describes a shape, not a
program. There is no reserved type-name prefix: you own your entire Type namespace within your org.
A schema that trips a guardrail fails preview with a specific reason, not a generic error:
{ "valid": false,
"errors": [{ "code": "META_SCHEMA", "message": "Record schema: Keyword \"if\" is not allowed", "path": "" }] }
Use the Type
Create a record against it exactly as in the quick start. The lifecycle difference between the two kinds shows up immediately. A notarize-only Type terminalizes on create:
curl -s -X POST "$AGLEDGER_URL/v1/records" \
-H "Authorization: Bearer $AGLEDGER_ADMIN_KEY" -H "Content-Type: application/json" \
-d '{ "type": "report.published.v1", "principalAgentId": "<agent-id>", "autoActivate": true,
"criteria": { "report_id": "RPT-1001", "title": "Q2 Summary" } }'
The record comes back RECORDED — signed, on the chain, and complete. A completion-bearing Type, by
contrast, comes back ACTIVE and waits for a completion before it can reach a verdict.
Fetch any Type's live schema and a copy-pasteable example with
GET /v1/schemas/report.published.v1; the full request and response shapes are in the API reference
at /openapi.json.
Add an automated accept/reject gate (the 10%)
When a principal needs the Server to render a deterministic accept/reject on a measurable
deliverable — the gate — give the Type a completionSchema and one or more fieldMappings. A
verdict of accept settles the record to FULFILLED; reject settles it to FAILED. Each mapping points
a criteria field at the evidence field the engine should check against it, with a comparison rule:
{
"type": "procurement.po-fulfilled.v1",
"recordSchema": { "type": "object", "required": ["po_number","quantity_ordered"],
"properties": { "po_number": {"type":"string","minLength":1},
"quantity_ordered": {"type":"number","minimum":1} }, "additionalProperties": false },
"completionSchema": { "type": "object", "required": ["quantity_delivered"],
"properties": { "quantity_delivered": {"type":"number","minimum":0} },
"additionalProperties": false },
"fieldMappings": [
{ "ruleId": "number:max-inclusive", "criteriaPath": "quantity_ordered",
"evidencePath": "quantity_delivered", "valueType": "number" }
]
}
Here a completion's quantity_delivered is checked against the record's quantity_ordered as an
upper bound, within an optional tolerance band. The engine validates structure and bounds; it does
not judge whether the work was good — the principal remains the real judge. The available rules and
value types are listed in the meta-schema response (fieldMappingValueTypes,
fieldMappingValueTypeSpec).
Evolve a Type safely
You do not edit a registered Type in place; you register a new version of the same type. Existing
versions stay queryable and existing records are untouched; the latest version becomes the default
for new records.
Because the default compatibilityMode is backward, the Server enforces that a new version still
accepts everything the old version did. A compatible change — adding an optional field — is accepted
as the next version:
# recordSchema gains an optional "summary"; everything else unchanged
curl -s -X POST "$AGLEDGER_URL/v1/schemas" -H "Authorization: Bearer $AGLEDGER_ADMIN_KEY" \
-H "Content-Type: application/json" -d '{ "type": "report.published.v1", … }'
# -> { "version": 2, "status": "ACTIVE" }
A breaking change — a new required field, or removing a property under additionalProperties:false
— is rejected with the specific reason, before anything is written:
{ "status": 400, "error": "VALIDATION_ERROR",
"detail": "RecordRow schema is not backward-compatible: Added required property \"author\"; Property \"url\" removed under additionalProperties:false — old instances carrying \"url\" no longer validate." }
Inspect what changed between any two versions with the diff endpoint, which labels each change and whether it breaks compatibility:
curl -s -H "Authorization: Bearer $AGLEDGER_ADMIN_KEY" \
"$AGLEDGER_URL/v1/schemas/report.published.v1/diff?from=1&to=2"
{ "record": { "changes": [
{ "path": "/properties/summary", "type": "ADD_OPTIONAL", "breaking": false,
"detail": "Added optional property \"summary\"" } ] },
"overallCompatibility": { "backward": true, "forward": true } }
To make a genuinely breaking change, register it under a new type name (for example
report.published.v2) rather than fighting the compatibility gate. List version history with
GET /v1/schemas/{type}/versions.
Retire a Type
Retirement is two steps, in order, by design.
# 1. Disable: hide from discovery and reject new records. Existing records are unaffected.
curl -s -X PATCH "$AGLEDGER_URL/v1/schemas/report.published.v1/disable" \
-H "Authorization: Bearer $AGLEDGER_ADMIN_KEY"
# 2. Delete: permitted only once the Type is DISABLED and no records reference it.
curl -s -X DELETE "$AGLEDGER_URL/v1/schemas/report.published.v1" \
-H "Authorization: Bearer $AGLEDGER_ADMIN_KEY"
Deleting a Type that still has active versions or referencing records returns 409 with the reason
("Cannot delete type with N ACTIVE version(s). Disable it first."). Re-enable a disabled Type with
PATCH /v1/schemas/{type}/enable. In practice most Types are disabled and left in place — deletion
is for mistakes, not for end-of-life.
Share a Type across orgs
A single company often runs more than one Server (per business unit, region, or environment) and wants the same Type on each (we call linking Servers federation). Sharing a Type is admin-to-admin file exchange — no shared registry, no signing infrastructure. The Server is not a trust broker; the channel you send the file through is.
Two facts make this work. First, the default publisher value local is deliberately
not shareable — asking a local Type for a shareable manifest is refused, with a hint:
{ "status": 409, "type": "/problems/reserved-publisher-label",
"detail": "Cannot emit an import-ready manifest for \"…\" — publisher \"local\" is reserved on the federation import path.",
"recoveryHint": "Re-register on the source with an explicit non-local publisher, or register on each peer under the same agreed label." }
So register a Type you intend to share with an explicit publisher label both sides agree on (for
example "publisher": "acme-corp"). Then export its manifest and send the file to the other admin:
curl -s -H "Authorization: Bearer $AGLEDGER_ADMIN_KEY" \
"$AGLEDGER_URL/v1/schemas/acme.po-fulfilled.v1/manifest" > acme-po.json
The receiving admin imports it on their Server (the manifest goes inside a manifest wrapper):
curl -s -X POST "$AGLEDGER_URL/v1/schemas/import" \
-H "Authorization: Bearer $AGLEDGER_ADMIN_KEY" -H "Content-Type: application/json" \
-d '{ "manifest": { … the manifest file contents … } }'
Second: identical schema content produces an identical manifestDigest on both Servers. That
content digest — not the type name — is how federated Servers confirm two installs are talking about
the same Type. Two admins who agree to label a Type acme-corp and import the same bytes get a
digest match; the agreement is out-of-band, and the engine verifies the bytes, not the label.
What is next
- Notarize against your Type. The quick start takes a single record from creation to offline verification.
- The data model. For what a record and the chain are underneath a Type, see records and the chain.
- Audit and verify at scale. The audit guide covers the database-independent export an auditor verifies with only your published keys.
- The full API surface. Request and response shapes for every schema endpoint are in the
OpenAPI reference at
/openapi.json(also served at/v1/openapi.json).
Air-gapped
Nothing here depends on our website, Docker Hub, or npm. Authoring, previewing, registering, versioning, and sharing Types are all calls to your own Server; manifests are files you move over whatever channel you already trust. A restricted-network Server registers and shares Types with no outbound calls.
Validated against API v1.0.0 on 2026-06-09 (Developer Edition, Docker Compose: _blank template,
POST /v1/schemas/preview, and POST /v1/schemas registration re-run live — the registered
manifestDigest reproduced byte-for-byte. The diff, compatibility-rejection, retire, and
cross-org share examples are illustrative and version-stable.)