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.

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

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.)