Sign, withdraw, attest to, build views of and verify Typed Standards
records from Python. The package drives
@typedstandards/cli 0.2.0 as a child
process. It holds no key, reads no signing seed, and computes none of the format's hashes: the CLI
does all of the format's work.
pip install typedstandards # or: uv add typedstandardsThe wheel carries the CLI and its dependencies (vendored at build time from this repository's
package-lock.json), so installing needs nothing from npm. Running needs Node.js: the wrapper and
the CLI need Node 20.19 or later, and a @typedstandards/host-core site build needs Node 22 or
later.
The wrapper looks for Node in this order:
TYPEDSTANDARDS_NODE, when set: the path (or name onPATH) of a Node binary;nodeonPATH.
Only the calls that run the CLI need Node: sign, withdraw, attest, view, verify,
cli_version(), and show without a precomputed result. With no Node, or one older than 20.19.0,
each of those raises typedstandards.NodeLocatorError, whose message names the floor and
TYPEDSTANDARDS_NODE. pin, badge_cell, comparison_cell, sidecar, show(record, result),
publish and publish_attestation run without Node.
Linux and macOS are tested. Windows is untested.
The CLI reads the signing seed (the standard base64 of a 32-byte Ed25519 seed) from one environment
variable, TYPEDSTANDARDS_SIGNING_SEED_B64, and from nowhere else. The wrapper never reads it: it
starts the CLI with the environment it inherited, and passes no environment of its own. Set the
variable from a secret store for the process that signs, for example:
op run --env-file=signing.env -- jupyter labwhere signing.env maps TYPEDSTANDARDS_SIGNING_SEED_B64 to a secret reference. Make a new seed
with:
openssl rand -base64 32Only sign, withdraw and attest need it; view and verify do not.
import typedstandards as ts
signed = ts.sign(
{
"type": "content/analysis/v1",
"producerProfile": "scripted-recomputation/example",
"captureMethod": "script-run",
"prompt": "Recompute the summary table.",
"promptVisibility": "full_text",
"queries": [],
"dataSources": [],
"cost": {"model": "none"},
"skillMetadata": {},
"trace": {},
"signer": {"bindingTier": "pseudonymous", "displayName": "Example analyst"},
},
output_file="analysis.ipynb",
) # signed inline under raw-bytes/v1
bundle = ts.view(signed, visibility="public", title="Example analysis")
result = ts.verify(bundle) # {ok, nodeId, failures, checks, lifecycle}Each function returns the CLI's stdout parsed as JSON. An input may be a mapping (written as JSON to a temporary file the CLI reads) or the path of a JSON file.
| Function | CLI command | Returns |
|---|---|---|
sign(input, *, output_file=None, output_url=None, content_type=None) |
sign |
{package, envelopeHash, signature} |
withdraw(input) |
withdraw |
{node, nodeId, signature} |
attest(input) |
attest |
{node, nodeId, signature} |
view(signed, *, visibility, attestations=(), trust_registry_url=None, package_url=None, title=None) |
view |
the commitment view, package inline |
verify(input, *, blobs=(), full=True) |
verify (--json when full) |
{ok, nodeId, failures, checks, lifecycle} |
The temporary files are removed before the function returns, whether the CLI succeeded or not. No
input reaches the CLI through a pipe, where CLI 0.2.0 fails on a document larger than a pipe buffer
holds. The CLI's
README describes each
command's inputs. What the CLI prints on stderr when it succeeds (attention readings, such as an
offline registry_unavailable) is logged at INFO on the typedstandards logger.
typedstandards.CLI_VERSION is the version of the vendored CLI ("0.2.0"), and
typedstandards.cli_version() asks the vendored CLI for it.
A non-zero exit raises a subclass of typedstandards.CliError, which carries exit_code, stderr
(the CLI's standard error) and command:
| Exit | Exception | Meaning |
|---|---|---|
| 1 | VerificationError |
a record, or the CLI's own result, did not verify; for verify, .document is the {ok: false, ...} verdict |
| 2 | UsageError |
an argument or an input is wrong |
| 3 | SeedError |
the seed's variable is missing or malformed |
| 4 | InternalError |
an internal error in the CLI |
@typedstandards/host-core inlines a top-level trustRegistry in every bundle it serves under a
registry, and CLI 0.2.0's verify refuses that key
(typedstandards#136). Until the wrapper
pins a CLI that accepts it, verify drops a bundle's top-level trustRegistry before the CLI
sees the bundle, and changes nothing else. A test pins that the CLI receives the same document
minus that one key.
Five helpers arrange and render around the CLI. None of them computes one of the format's hashes, reads the seed, or verifies anything itself.
import io
import pandas as pd
import typedstandards as ts
# Before signing: pin each input, and add the reader's badge and the comparison cell.
content, entry = ts.pin("https://data.example.org/resource/abcd-1234.csv", licence="CC-BY-4.0")
frame = pd.read_csv(io.BytesIO(content))
ts.badge_cell(
"https://records.example.org/bundles/analysis.bundle.json",
capture_method="script-run",
notebook="analysis.ipynb",
)
ts.comparison_cell(
"analysis.ipynb",
{"rows": 1204, "mean_fare": 13.75},
recompute="recompute_key_metrics()",
captured_at="2026-10-03T12:00:00Z",
)
# Sign (record is an envelope input like the one under Use), then serve and show.
signed = ts.sign({**record, "queries": [entry]}, output_file="analysis.ipynb")
bundle = ts.view(signed, visibility="public", title="Example analysis")
ts.sidecar(bundle, "analysis.ipynb") # writes analysis.ipynb.record.yaml
ts.show(bundle) # in Jupyter; ts.show(bundle, marimo=True) in Marimopin(url, *, licence=None, dataset_id=None, portal_metadata=None, save=None, ...)fetchesurlonce and returnsPinned(content, entry): the response body, and a retrieval entry for the record'squeries[]withurl,sha256(of the body),bytes,httpStatusandfetchedAt(ISO 8601 UTC) underarguments. A URL shaped like an open-data portal resource (…/resource/<id>[.ext]or…/api/views/<id>/rows.<ext>,<id>beingxxxx-xxxx) gets a second request to<origin>/api/views/<id>, and the entry gains the portal'srowsUpdatedAt(epoch seconds) anddatasetId. A response that is not 2xx raises.save=writes the bytes to that path and the entry to<path>.pin.json. The SHA-256 is the one digest the package computes: a signed assertion inqueries[]that no check recomputes.badge_cell(bundle_url, *, capture_method, notebook=None, host=None, marimo=False)writes the verifier badge, linked tohttps://typedstandards.org/verify?url=<the bundle URL, percent-encoded>as@typedstandards/host-corewrites it, above a two-row table (the host and the capture method). Withnotebook, it is inserted as the notebook's first cell (idtypedstandards-badgeon nbformat 4.5). Withmarimo=True, it returns the source of amo.md(...)cell to paste into the app. The cell is written before signing and is part of the signed bytes, so it names no hash and no time; a URL or value holding a 64-hex string, a date or a time is refused. The URL's host and port are not read as a time (192.168.1.10:8080is accepted).comparison_cell(notebook, values, *, recompute, captured_at)appends the comparison cell of spec §8.7.4 as the last cell (idtypedstandards-comparison): the values as Python literals,current = <recompute>, and a loop that prints each delta. Values areNone,bool,int, finitefloat,str, and lists and str-keyed dicts of those; anything else is refused.sidecar(view, artifact, *, directory=None)writes the commitment view as YAML (spec §8.8.3) beside the artifact: every fieldviewprinted except the inlinepackageand a served bundle'strustRegistry, which are not §8.8.1 fields. The file is named<artifact's file name>.record.yaml, extension kept (analysis.ipynb.record.yaml): the spec's<artifact-basename>does not say whether the extension stays, and keeping it is the POSIX basename and cannot collide when two artifacts share a stem (analysis.ipynbbesideanalysis.py).show(record, result=None, *, role_path=("role",), marimo=False)renders a record (whatvieworsignprinted) with itsverify --jsonresult: the type, the role, the signer, the hash,createdAt, thevcsRef(marked as asserted and not fetched), the status with its reason or successor, one line per check, and a sentence saying that verification does not say the analysis is correct. Withoutresultit runsverify. In Jupyter it returns an object with_repr_html_; withmarimo=True,mo.Html. The role is a signed assertion the signer made, read from the package'sextensionsatrole_path(by defaultextensions["role"], a string or a list of strings);showlabels it as the signer's and checks nothing about it.
The notebook helpers edit the notebook as JSON, splicing the new cell into cells so every
other byte of the file stays as written. httpx is imported only inside pin and the publish
calls, PyYAML only
inside sidecar, and marimo only inside a Marimo call; IPython, marimo and nbformat
are not dependencies.
publish writes a signed record to a GitHub repository made from the
host template in its publish mode,
whose workflow builds the site from the repository's host.json and deploys it to GitHub Pages.
Each call is one commit, made through GitHub's Git Data API; the host's workflow then builds and
deploys it. publish does not wait for the deploy. The template's README sets a host up for this:
Publishing from a notebook.
That setup names your key in host-policy.json's signer, and publish refuses a record signed
by any other key. The CLI prints a did:key only in what it signs, so read yours from the first
record you sign, before its first publish:
signed = ts.sign(record, output_file="dog-licensing.ipynb")
signed["package"]["signer"]["identifier"] # did:key:z6Mk…: the policy's signerA copy starts with no records; its first publish adds the first.
import typedstandards as ts
host = ts.GitHubPagesHost("owner/repo") # branch="main"; the token from TYPEDSTANDARDS_GITHUB_TOKEN
signed = ts.sign(record, output_file="dog-licensing.ipynb") # record: an envelope input, as under Use
receipt = ts.publish(signed, host=host, notebook="dog-licensing.ipynb", title="Dog licensing by district")
receipt["verify_url"] # the verifier's link for the served bundle
withdrawal = ts.withdraw(
{"targetNodeId": signed["envelopeHash"], "reason": "...", "signer": signed["package"]["signer"]}
)
ts.publish_attestation(withdrawal, host=host, name=receipt["name"])GitHubPagesHost(repository, *, branch="main", token=None, ...): the repository asowner/name. Itsreprshows the repository and the branch only, and its errors never quote an argument. A repository or branch with a part shaped like a GitHub token (a token prefix such asghp_orgithub_pat_, then 30 or more token characters) is refused; a short name with such a prefix, such asghp_notes, is accepted. The HTTP client a call builds ignores proxy and certificate environment variables; passclient=(anhttpx.Client) for those.publish(signed, *, host, title, name=None, notebook=None, role="notebook", revises=None)writes whatsignprinted (or its path) torecords/<name>.signed.jsonand appends its entry tohost.json:{name, signed, attestations: [], title, extensions: {role}}.host.jsonis rewritten with two-space indentation; no other field of it changes.publish_attestation(node, *, host, name)writes whatwithdraworattestprinted (or its path) torecords/<name>.<kind>-<eight hex of its nodeId>.jsonand adds that path to the record'sattestations, in one commit. A node already listed there is not written again.
The default name, with notebook=, is <stem>/<date>-<eight hex>: the notebook file's stem,
the date of the record's createdAt (UTC), and the first eight hex characters of its
envelopeHash, for example dog-licensing/2026-10-04-ebb38315. The signed document does not carry
the notebook's file name, so the stem comes from the argument. A rerun signs to another
envelopeHash, so it gets a new name.
A record whose envelopeHash the host already lists is not written again, under any name: the
call writes nothing (written: False), and its receipt names the entry that lists it, with that
entry's bundle_url and verify_url. host-core's build refuses one envelopeHash listed twice,
so a second entry would stop every later deploy. To find a listed hash, each call makes one read of
each listed record's signed file.
With an explicit name=, a name listed with another record is refused, unless revises= is
given. Then the
record is written under <name>-<its first eight hex>, and the revises= node goes on the listed
record's entry, in the same commit. Sign the node first:
node = ts.attest(
{
"type": "attestation/revises/v1",
"targetNodeId": prior["envelopeHash"], # the listed record
"successorNodeId": signed["envelopeHash"], # this one
"signer": signed["package"]["signer"],
}
)
ts.publish(signed, host=host, name="dog-licensing", title="Dog licensing, rerun", revises=node)publish compares the node's fields only: its type, its successorNodeId with this record's
envelopeHash, and its targetNodeId with the listed record's. Under the default name, a
revises= node goes on the entry of the listed record it targets.
A name is /-separated segments of letters, digits, ., _ and -, with no . or ..
segment (host-core's rule), and no records or evidence segment, which the verifier reads as a
record page's URL rather than a bundle's.
Each raises typedstandards.PublishRefusedError before any write request:
- a token that does not start
github_pat_, or that holds whitespace, a quote orop://(the message names where the token came from, never its value); - a name that fails the rule above, or a bundle URL with a
recordsorevidencesegment; - an empty title;
- a record whose
outputis a BlobRef (signed withoutput_url=): the host serves the signed file only, so sign withoutput_file=alone; - a record or node whose signer is not the
signerin the host'shost-policy.json, or whose type the policy does not name; - a role that no rule for
activerecords inhost-policy.jsonadmits: such a record would fail the host's build, and with it every later deploy; - a listed name with another record and no
revises=, or arevises=whose fields do not match; - anything host-core's build would refuse once the commit lands: a signed file it cannot read as
UTF-8 JSON; a signature without its
signatureandpublicKey, or with akidother than the signer; an emptycreatedAt; under a registry, a signer, display name or key other than the host's first record's; withregistry: null, a signer that is not a pseudonymousdid:key; a path another entry already names; ahost.jsonthat fails host-core's manifest rule; and a host whose build already fails on a listed record; - for
publish_attestation, a withdrawal or supersession that would leave the record in a status no rule ofhost-policy.jsondisplays (the template's policy has no rule forsuperseded); and a claim-to-claim node (corroborates,contradicts), a name the host does not list, or a node whosetargetNodeIdis not that record'senvelopeHash.
A ref update GitHub does not accept re-reads the branch head first, since a write that errored may
have landed. If the commit did not land and the branch moved, publish plans again from the new
head once; a second failure raises typedstandards.PublishError, as does an API error, whose
message names the request and GitHub's own message.
| Key | Value |
|---|---|
name |
the name the record is listed under (the derived name for a revision) |
commit |
the new commit's id, or None when nothing was written |
bundle_url |
<origin>/bundles/<name>.bundle.json, from host.json's origin |
verify_url |
the verifier's link for bundle_url, the same link badge_cell writes |
registry_url |
<origin>/.well-known/typed-publisher.json, or None for a host with no registry |
written |
True for a new commit, False when the host already listed it |
run |
None: publish does not wait for the deploy |
A fine-grained personal access token, for the one publishing repository, with Contents: read
and write and nothing else (enough for a public repository; a private one is unmeasured). Give
it an expiry. It comes from token=, else from TYPEDSTANDARDS_GITHUB_TOKEN, read when a publish
runs; it is added to each request's Authorization header by an httpx.Auth that holds it and
withholds it from its repr, whether the call builds its client or uses one given as client=. It
is in no message, log record, receipt or file. When an exception escapes a request, the locals of
the frames it carries, httpx's, httpcore's and h11's included, are cleared before it propagates,
so a traceback with frame locals does not show the token; the tests check this with httpx's mock
transport and with its real transport over a failing stream. Never write it as a literal in a cell. Locally,
set it with the seed, from the secret store that starts the kernel (op run --env-file=… -- jupyter lab); in a hosted notebook, from the hosting service's secret store, as for the seed
below.
Commits made through the API are unsigned, so a branch rule that requires signed commits or pull requests refuses them. The template's README gives the ruleset for a publishing branch: no deletion and no force push.
A hosted kernel has no launcher to set the seed's variable, so the author's own code sets it, in one cell that prints nothing:
import os
os.environ["TYPEDSTANDARDS_SIGNING_SEED_B64"] = read_secret("TYPEDSTANDARDS_SIGNING_SEED_B64")where read_secret stands for the hosting service's own call that reads a stored secret. The
wrapper still never reads the variable; the CLI does.
The seed must never be pasted into a cell, printed (by print, %env, or a cell whose last
expression is the value), or saved in the .ipynb in any other way: the notebook is the file that
is signed and published. In a hosted notebook, the hosting service's runtime holds the seed for as
long as the kernel runs. Make the seed once, outside any notebook (openssl rand -base64 32), and
keep it in a password manager as well as the service's secret store: a record signed by a key that
is lost can never be withdrawn or revised.
In GitHub Codespaces, a Codespaces secret named TYPEDSTANDARDS_SIGNING_SEED_B64 arrives in the
codespace as an environment variable, so no line is needed; neither a repository Actions variable
nor an Actions secret is a place for the seed.
Each wrapper release pins one CLI version exactly. A CLI upgrade reaches users as a wrapper release that moves the pin.
See CLAUDE.md for the development loop and the checks CI runs.
MIT. The wheel also carries each vendored npm package's own licence file: five are MIT, and
canonicalize is Apache-2.0.