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_API_URL=https://agledger.example.com
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_API_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_API_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_API_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_API_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. Standard JSON Schema applicators - conditionals
(if / then / else) and combiners (allOf / anyOf / oneOf / not) - are supported: they are
pure shape validation (e.g. "if determination is denied, a denialBasis is required"). Execution-surface
keywords ($data, $async, $code) 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 \"$async\" 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_API_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).
When the gate runs in auto mode, submitting a completion returns the outcome inline: the response
carries a settlementSignal (a recommendation of settle or hold, plus the engine outcome and a
machine-readable reasonCode), so the caller learns accept-versus-reject at completion time rather
than following up with a read. The exact field shapes are in /openapi.json.
Per-record tolerance is cappable. A record may declare a tolerance band that widens a rule's
comparison at verdict time. Since API v1.2.0 the Type author has the last word: give a fieldMapping
a maxTolerance to pin the widest band a record of this Type may declare, or 0 to forbid
tolerance on that rule outright - an undodgeable threshold gate. A record declaring more is
rejected with 400 at every tolerance write (create, bulk, draft update, counter-propose) -
rejected, not clamped - and the Type's quickStart omits tolerance keys the author forbids. This
closes a real screening finding: a 1.0 match score auto-clearing a 0.55 threshold because the
record carried tolerance: 0.85.
Expression rules can bind the parent record. For checks no comparison verb expresses, a
fieldMapping with valueType: "expression" evaluates a boolean expression over bound contexts:
criteria, evidence, tolerance, metadata, record (the current record's engine-stamped
identity), and - from API v1.2.0 - parent: the parent record's server-truth projection
(parent.id, parent.status, parent.type, and the engine-signed parent.created_at,
parent.activated_at, parent.fulfilled_at). Waiting-period and two-step gates become fully
server-truthed - both timestamps below are engine-signed, neither is caller-supplied:
daysBetween(parent.created_at, record.created_at) >= criteria.minWaitDays
Parent criteria and evidence are deliberately not bindable; the projection is identity plus
engine-stamped timestamps. The binding also fails closed: a parent.* rule evaluated on a record
created without parentRecordId fails that check (in auto mode the record settles FAILED) rather
than silently passing. Bindings, helper functions, and expression limits are listed in the
meta-schema response.
Gate modes have one guard. Whether the engine renders the verdict (auto) or the principal
does (principal) is the record's gateMode - a per-record override over the Type's
defaultGateMode. A Type that declares defaultGateMode: "principal" and carries no gate rules
rejects a per-record gateMode: "auto" with 400: with nothing to evaluate, auto would settle
unconditionally. Omit the override, or register a version with fieldMappings first.
The wiring echoes on write. Registration, import, and version responses carry the compiled
rulesConfig - the live syncRuleIds, asyncRuleIds, and fieldMappings the engine will
evaluate - so you can confirm the gate wiring landed without a follow-up GET. On the handshake
side, POST /v1/records/{id}/accept takes its rationale as message, accepting reason and
notes as aliases.
Deadlines and expiry
Give a record a deadline (ISO 8601, and it must be in the future at creation) when the work is
time-bound. A background sweep checks open records against their deadlines roughly once a minute. Any
record still open past its deadline - proposed-but-never-accepted, accepted-but-never-started, active,
or awaiting a completion or verdict - moves to the terminal state EXPIRED and fires a signed,
webhook-deliverable record.expired event (its payload carries previousStatus, deadline, and
type). A notarize-only record has no deadline and never expires (it terminalizes at RECORDED on
create); a private, un-offered draft is also left alone.
The effective expiry is deadline plus any tolerance.graceSeconds (default 0) - a grace band you
set per record. There is no other knob: the deadline and grace band are the controls, and the sweep
cadence is not customer-configurable.
This is the building block for catching a skipped or abandoned step. A notarize-only record can
only confirm steps that happened; to get an active alert that a required step did not happen,
model that step as a record with a deadline. If the step is never completed, the record expires and
the record.expired event tells you - no polling required.
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_API_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_API_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_API_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_API_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_API_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_API_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.2.0 on 2026-07-06 (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. The tolerance-cap, parent-binding,
gate-mode guard, and rulesConfig-echo behavior reflects API v1.2.0 as shipped.)
Reviewed for API v1.3.2 on 2026-07-13: type registration and lifecycle are unchanged. v1.3.2
hardens gate evaluation to fail closed on unresolvable rule references and pins terminal-reason
labels to a closed enum; the added record-response fields (signedStatement.signedAt, recordStatus
on the verdict reply) are in the API reference. No schema-authoring behavior changed.