Distil and download a skill from a recorded procedure
We will seed a simulated refund-decision procedure in an empty test workspace,
request distillation, inspect the generated procedure and its provenance, then
explicitly publish and download a ZIP if the service creates a skill. We will
verify that the ZIP contains SKILL.md and record the disposition of every
resource. Loading or executing the skill in another agent is outside this lesson.
| Agent Skills is a NAMS preview feature exposed through REST and MCP, not an SDK skill accessor. A run can be withheld or fail. Offline contract checks do not establish that a live workspace will pass the service’s quality gates. |
Before you begin
Provision a second, empty NAMS workspace for this lesson from the NAMS dashboard before continuing.
-
Python 3.10 or newer and a POSIX shell.
-
A newly provisioned empty NAMS test workspace reserved for this fixture, with a workspace data key permitting the lesson’s memory operations and
skills:read/skills:write(see Key categories). -
An identified workspace owner/operator and a verified disposal procedure or explicitly agreed retention arrangement for the records described below.
The generation scope is the entire selected workspace. A zero-conversation count does not prove the workspace has no entities, reasoning records or skills. Have its owner verify that the new workspace is empty before seeding. A valid data key alone establishes neither this condition nor administrative permission to retire a workspace. See Authentication.
The fixture creates one conversation, two messages, nine application-authored steps and nine simulated tool calls. Extraction may create entities; generation creates a run and may create a skill. There is no verified public deletion route for steps, tool calls, skill runs or skills. Publishing and downloading do not remove them. Establish the owner and actual disposition arrangement before running seed; a generic promise to use a management interface is not a verified disposal procedure. See Hosted cleanup limits.
All orders and decisions are simulated. No payment is issued and no generated procedure is executed by this lesson.
Install the published SDK, then create the local files shown in this lesson:
Create a local folder and virtual environment for the examples on this page. The commands reuse an existing environment without changing its files:
mkdir -p ~/agent-memory-tutorials
cd ~/agent-memory-tutorials
if [ -e .venv ]; then
printf '%s\n' 'Using the existing virtual environment.'
else
python3 -m venv .venv
fi
source .venv/bin/activate
Expected: ~/agent-memory-tutorials is your working directory and its virtual environment is active. Install the published SDK with the command below.
Each complete code block labelled Save as names a file to create in this folder using your editor. Copy the entire block, including imports and the entry point. Expand each helper disclosure and use Copy code to copy its full source. Keep all files together so their imports resolve.
When continuing from another tutorial or guide, retain the existing environment, configuration, session files, and .tutorial-state/. Reuse unchanged helper files; compare an existing file before replacing it, and finish any pending cleanup or recovery before changing the code that owns its state.
python -m pip install 'neo4j-agent-memory[nams]==0.7.0'
python -c "from importlib.metadata import version; \
import neo4j_agent_memory; \
assert version('neo4j-agent-memory') == '0.7.0'; \
print('SDK 0.7.0 import verified')"
Expected: SDK 0.7.0 import verified. Keep agent-memory-tutorials as your
working directory. Do not reuse a nonempty workspace
merely because it was used for the preceding lesson.
Set the key for the owner-verified empty workspace:
export MEMORY_API_KEY="replace-with-the-empty-workspace-key"
export MEMORY_ENDPOINT="https://memory.neo4jlabs.com/v1"
unset MEMORY_WORKSPACE_ID
python -c "from neo4j_agent_memory import NamsSettings; \
s=NamsSettings(); \
print(s.backend, s.nams.endpoint)"
Expected: nams https://memory.neo4jlabs.com/v1. This checks configuration only;
it does not authenticate or prove emptiness. Keep these credentials and this
tutorial directory for every following command. The maintained client uses
the same resolved settings for its SDK and direct HTTP calls.
Before seeding, identify the workspace name or ID in the dashboard and its
responsible operator. Supply those non-secret labels in the seed command below.
They are saved as operator notes; they do not change request routing or prove
workspace identity. Because this lesson unsets MEMORY_WORKSPACE_ID, the
ledger’s selector can be null: the workspace key selects the workspace.
Never put a key in the workspace label or disposal-owner note.
Step 1: Seed the exact procedure
The script checks the service’s distillation capability before any fixture write. Disabled or unavailable distillation stops seeding. The explicit empty-workspace flag records the owner’s check; it does not perform workspace provisioning or independently certify emptiness.
Create the three shared helpers below, then save skills_quickstart.py in
the same lesson folder:
Copy these files into the same lesson folder. The final row is the program we will run; the other files are tutorial support, not SDK APIs. Reuse a helper only when its contents match. Finish pending cleanup/recovery before replacing code that owns an existing ledger.
| File | Purpose | Continuing reader |
|---|---|---|
|
Private ledger and local inspection |
Reuse unchanged |
|
Supported scoped cleanup; retained resources stay explicit |
Reuse unchanged |
|
Bounded polling and ontology support shared with the preceding lesson |
Reuse unchanged |
|
The learner-facing seed, generate, review and disposition commands |
New lesson file |
Open the helper below and save its complete source as hosted_tutorial_state.py in ~/agent-memory-tutorials.
Show hosted_tutorial_state.py
hosted_tutorial_state.py"""Private, restartable state for the hosted REST tutorials; no I/O at import."""
from __future__ import annotations
import hashlib
import json
import os
import re
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlsplit, urlunsplit
from uuid import uuid4
def read_state(path):
"""Read a local ledger without credentials, SDK imports, or service access."""
path = Path(path)
if path.is_symlink():
raise RuntimeError("Refusing a symlink for tutorial state")
data = json.loads(path.read_text())
if not isinstance(data, dict) or data.get("schema_version") != 1:
raise RuntimeError("Unsupported or incomplete tutorial state")
if not isinstance(data.get("identity"), dict):
raise RuntimeError("Missing tutorial identity")
if (
not isinstance(data.get("resources"), dict)
or not data.get("run_token")
or "pending" not in data
):
raise RuntimeError("Incomplete tutorial state")
if any(not isinstance(entries, dict) for entries in data["resources"].values()):
raise RuntimeError("Invalid resource ledger")
if any(
not isinstance(entry, dict)
for entries in data["resources"].values()
for entry in entries.values()
):
raise RuntimeError("Invalid resource disposition")
datetime.fromisoformat(data["started_at"])
return data
def new_run_status(data):
"""Classify recorded evidence only; never infer absence from an empty ledger."""
if data.get("pending"):
return "needs_reconciliation"
resources = data["resources"]
entries = [entry for group in resources.values() for entry in group.values()]
if (
data.get("remote_write_started") is False
and not entries
and not any(data.get(key) for key in ("conversation_id", "run_id", "skill_id"))
and not data.get("ontology", {}).get("clone_id")
):
return "no_remote_write_started"
disposed = data.get("cleanup_complete") is True or (
data.get("ontology", {}).get("status") == "complete"
and data["ontology"].get("restored") is True
and data["ontology"].get("deleted") is True
)
if (
disposed
and entries
and all(entry.get("status") in {"deleted", "deleted_with_ontology"} for entry in entries)
):
return "recorded_disposition_complete"
return "owner_disposition_required"
# Restoration target and exercise outcome shown by inspect-file.
ONTOLOGY_SUMMARY_KEYS = (
"previous",
"strict",
"clone_id",
"template",
"status",
"restored",
"deleted",
"exercise_error",
)
def inspect_file(path):
"""Return a redacted local summary; this never authenticates or enables writes."""
data = read_state(path)
endpoint = urlsplit(str(data["identity"].get("endpoint", "")))
# Do not expose credentials/query parameters even from a manually altered file.
endpoint = urlunsplit(
(endpoint.scheme, endpoint.netloc.rsplit("@", 1)[-1], endpoint.path, "", "")
)
summary = {
"local_only": True,
"service_checked": False,
"lesson": data.get("lesson"),
"run_token": data["run_token"],
"started_at": data["started_at"],
"identity": {
"endpoint": endpoint,
"workspace_id": data["identity"].get("workspace_id"),
},
"operator_context": data.get("operator_context", {}),
"pending": data.get("pending"),
"new_run_status": new_run_status(data),
"resources": {
kind: {
resource_id: {
key: entry[key] for key in ("status", "owned", "disposition") if key in entry
}
for resource_id, entry in entries.items()
}
for kind, entries in data["resources"].items()
},
}
ontology = data.get("ontology")
if isinstance(ontology, dict):
# The owner needs the saved prior binding to restore the workspace.
# Schema documents are omitted; the IDs identify them exactly.
binding_keys = ("version_id", "ontology_id", "revision", "validation_mode")
summary["ontology"] = {
key: (
{k: value[k] for k in binding_keys if k in value}
if isinstance(value, dict)
else value
)
for key, value in ontology.items()
if key in ONTOLOGY_SUMMARY_KEYS
}
return summary
def check_new_run(path, next_state):
"""Permit a separate path only after no-write proof or recorded full disposal."""
status = new_run_status(read_state(path))
if status not in {"no_remote_write_started", "recorded_disposition_complete"}:
raise RuntimeError(
"No automatic new run: uncertain, retained, or legacy state needs owner disposition"
)
next_state = Path(next_state)
if next_state.exists() or next_state.is_symlink():
raise RuntimeError("Choose an unused state path; the previous run must remain intact")
if next_state.parent.resolve() == Path(path).parent.resolve():
raise RuntimeError("Use a separate run directory so reports and archives cannot collide")
if next_state.parent.exists() and any(next_state.parent.iterdir()):
raise RuntimeError("Choose a new or empty run directory; preserve its existing reports")
return status
def digest(text: str) -> str:
return hashlib.sha256(text.encode()).hexdigest()
def connection(settings):
"""Use the same resolved NamsSettings for SDK and tutorial HTTP requests."""
config = settings.nams
parts = urlsplit(str(config.endpoint).rstrip("/"))
if parts.scheme not in {"http", "https"} or not parts.netloc:
raise ValueError("A hosted HTTP(S) endpoint is required")
if parts.username or parts.password or parts.query or parts.fragment:
raise ValueError("Endpoint must not contain credentials, a query, or a fragment")
if config.transport_mode == "bridge" or (
config.transport_mode == "auto" and not re.search(r"/v\d+(?:/|$)", parts.path)
):
raise ValueError("These tutorials require the hosted REST API")
endpoint = urlunsplit((parts.scheme.lower(), parts.netloc.lower(), parts.path, "", ""))
key = config.api_key.get_secret_value() if config.api_key else ""
if not key:
raise ValueError("Set MEMORY_API_KEY before starting the tutorial")
# Tutorial identity must not differ between the SDK and direct HTTP calls.
headers = dict(config.headers)
if any(k.lower() in {"authorization", "x-workspace-id"} for k in headers):
raise ValueError("Use api_key/workspace_id settings, not overriding auth/workspace headers")
headers["Authorization"] = f"Bearer {key}"
if config.workspace_id:
headers["X-Workspace-Id"] = config.workspace_id
resolved = {"endpoint": endpoint, "workspace_id": config.workspace_id}
return resolved, headers, config.timeout, key
def credential_digest(key: str, salt: str) -> str:
"""Bind state to the credential that seeded it, without keeping a directly
comparable digest of that credential on disk. A per-run salt and a
deliberately slow KDF mean the stored value answers one question -- "is this
the same key as last time?" -- and is not worth attacking offline. Message
content keeps ``digest``: it is a change detector, not a secret."""
return hashlib.scrypt(
key.encode(), salt=bytes.fromhex(salt), n=16384, r=8, p=1, maxmem=64 * 1024 * 1024, dklen=32
).hex()
def identity(settings, salt=None):
resolved, _headers, _timeout, key = connection(settings)
salt = salt or os.urandom(16).hex()
return {**resolved, "credential_salt": salt, "credential_digest": credential_digest(key, salt)}
def http_client(settings, **kwargs):
import httpx
resolved, headers, timeout, _key = connection(settings)
return httpx.AsyncClient(
base_url=resolved["endpoint"] + "/", headers=headers, timeout=timeout, **kwargs
)
def private_json(path: Path, data, *, exclusive=False):
"""Write without exposing partial JSON or replacing a previous exercise."""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
if path.is_symlink():
raise RuntimeError("Refusing a symlink for tutorial state")
content = json.dumps(data, indent=2, allow_nan=False) + "\n"
if exclusive:
try:
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
except FileExistsError as exc:
raise RuntimeError("State already exists; inspect or recover the prior run") from exc
with os.fdopen(fd, "w") as output:
output.write(content)
output.flush()
os.fsync(output.fileno())
return
fd, temporary = tempfile.mkstemp(prefix=path.name + ".", dir=path.parent)
try:
with os.fdopen(fd, "w") as output:
output.write(content)
output.flush()
os.fsync(output.fileno())
os.replace(temporary, path)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
class TutorialState:
def __init__(self, path, data):
self.path = Path(path)
self.data = data
@classmethod
def create(cls, path, settings, lesson, *, workspace_label=None, workspace_owner=None):
data = {
"schema_version": 1,
"lesson": lesson,
"identity": identity(settings),
"run_token": uuid4().hex,
"started_at": datetime.now(timezone.utc).isoformat(),
"resources": {},
"pending": None,
# Written durably by begin() before the first remote mutation.
# Missing in an older ledger means unknown, never proof of no writes.
"remote_write_started": False,
"operator_context": {
"workspace_label": workspace_label,
"workspace_owner": workspace_owner,
},
}
private_json(Path(path), data, exclusive=True)
return cls(path, data)
@classmethod
def load(cls, path, settings, lesson=None):
path = Path(path)
data = read_state(path)
stored = data["identity"]
salt = stored.get("credential_salt")
if not isinstance(salt, str) or not re.fullmatch(r"[0-9a-f]{32}", salt):
raise RuntimeError("Tutorial identity is missing its credential salt; do not reseed")
if stored != identity(settings, salt):
raise RuntimeError(
"Endpoint, workspace, or credential changed; verify ownership before recovery"
)
if lesson is not None and data.get("lesson") != lesson:
raise RuntimeError("State belongs to another tutorial")
return cls(path, data)
def save(self):
private_json(self.path, self.data)
def record(self, kind, resource_id, **metadata):
if not isinstance(resource_id, str) or not resource_id.strip():
raise RuntimeError(f"Missing returned {kind} ID; retained pending operation")
resources = self.data["resources"].setdefault(kind, {})
resources.setdefault(resource_id, {"status": "retained"}).update(metadata)
self.save()
def begin(self, operation):
if self.data.get("pending"):
raise RuntimeError("Unresolved write; inspect the retained state before retrying")
self.data["remote_write_started"] = True
self.data["pending"] = operation
self.save()
def finish(self):
self.data["pending"] = None
self.save()
def inspect(self):
data = json.loads(json.dumps(self.data))
data["identity"].pop("credential_salt", None)
data["identity"].pop("credential_digest", None)
return data
def remember_message(state, message):
role = getattr(message.role, "value", message.role)
state.record("message", str(message.id), content_sha256=digest(message.content), role=role)
async def verify_messages(client, state):
"""Read-only exact-ID/content verification; safe to run in a fresh process."""
expected = state.data["resources"].get("message", {})
if not expected or not state.data.get("conversation_id"):
raise RuntimeError("No complete message seed to verify")
history = await client.short_term.get_conversation(state.data["conversation_id"])
actual = {str(m.id): m for m in history.messages}
if set(actual) != set(expected):
raise RuntimeError("Conversation has missing or unrecorded messages; retained state")
for message_id, entry in expected.items():
message = actual[message_id]
role = getattr(message.role, "value", message.role)
if digest(message.content) != entry["content_sha256"] or role != entry["role"]:
raise RuntimeError(f"Message readback differs: {message_id}")
print(f"Verified: exact IDs, roles and content for {len(expected)} stored messages")
def main(argv=None):
"""Local diagnostics only; importing this module still performs no I/O."""
import argparse
parser = argparse.ArgumentParser(description="Inspect hosted lesson state without credentials")
commands = parser.add_subparsers(dest="command", required=True)
inspect = commands.add_parser("inspect-file", help="Print a redacted local summary; no network")
inspect.add_argument("state", type=Path)
retry = commands.add_parser("check-new-run", help="Check a separate path; preserve both files")
retry.add_argument("state", type=Path)
retry.add_argument("--next-state", type=Path, required=True)
args = parser.parse_args(argv)
if args.command == "inspect-file":
print(json.dumps(inspect_file(args.state), indent=2))
else:
status = check_new_run(args.state, args.next_state)
print(f"Recorded new-run check: {status}; original state preserved")
print(f"Use --state {args.next_state} only after rechecking the lesson prerequisites")
print("No service state was checked and no new file was created")
if __name__ == "__main__":
main()
Open the helper below and save its complete source as hosted_tutorial_cleanup.py in ~/agent-memory-tutorials.
Show hosted_tutorial_cleanup.py
hosted_tutorial_cleanup.py"""Scoped cleanup using the hosted API's supported conversation/entity routes."""
from __future__ import annotations
import asyncio
import time
from datetime import datetime, timezone
from urllib.parse import quote
# Start from retained message IDs, then inspect all sources and neighboring IDs.
PROVENANCE_QUERY = """
MATCH (m) WHERE m.id IN $message_ids
MATCH (m)-[:EXTRACTED_FROM]-(e:Entity)
WITH DISTINCT e
OPTIONAL MATCH (e)-[:EXTRACTED_FROM]-(source)
WITH e, collect(DISTINCT source.id) AS sources, count(DISTINCT source) AS source_count
OPTIONAL MATCH (e)--(neighbor)
RETURN e.id AS id, e.createdAt AS created_at, sources, source_count,
collect(DISTINCT neighbor.id) AS neighbors, count(DISTINCT neighbor) AS neighbor_count
"""
RESIDUAL_QUERY = """
MATCH (n) WHERE n.id IN $ids OR n.conversationId = $conversation
RETURN n.id AS id, labels(n) AS labels
"""
async def request_json(http, method, route, **kwargs):
response = await http.request(method, route, **kwargs)
response.raise_for_status()
return response.json()
async def query(http, cypher, params):
result = await request_json(http, "POST", "query", json={"cypher": cypher, "params": params})
rows = result.get("rows")
if not isinstance(rows, list):
raise RuntimeError("Query did not return rows; retained tutorial state")
return rows
async def wait_for_extraction(http, conversation_id, count, *, timeout=60.0, interval=1.0):
if timeout <= 0 or interval <= 0:
raise ValueError("Polling timeout and interval must be positive")
deadline = time.monotonic() + timeout
route = f"conversations/{quote(conversation_id, safe='')}/extraction-status"
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError("Extraction did not settle; no cleanup was attempted")
result = await asyncio.wait_for(request_json(http, "GET", route), remaining)
summary = result.get("summary")
if not isinstance(summary, dict) or any(
type(value) is not int or value < 0 for value in summary.values()
):
raise RuntimeError("Invalid extraction status; no cleanup was attempted")
if any(summary.get(key, 0) for key in ("failed", "error", "cancelled")):
raise RuntimeError("Extraction failed; retain state for inspection")
terminal = {"done", "completed", "skipped"}
pending = {"pending", "processing", "running", "in_progress", "queued"}
if set(summary) - terminal - pending:
raise RuntimeError("Unknown extraction status; retain state for inspection")
if sum(summary.values()) == count and not any(summary.get(key, 0) for key in pending):
return
await asyncio.sleep(min(interval, max(0, deadline - time.monotonic())))
async def delete_and_verify(http, route):
response = await http.delete(route)
if response.status_code != 404:
response.raise_for_status()
readback = await http.get(route)
if readback.status_code != 404:
readback.raise_for_status()
raise RuntimeError(f"Resource still exists after DELETE: {route}")
def exclusively_owned(row, message_ids, started_at):
try:
created = datetime.fromisoformat(row["created_at"].replace("Z", "+00:00"))
started = datetime.fromisoformat(started_at)
# collect(node.id) omits anonymous nodes. Compare node counts as well
# so missing IDs cannot hide another source or adjacent resource.
for field, count_field in (("sources", "source_count"), ("neighbors", "neighbor_count")):
ids = row.get(field)
count = row.get(count_field)
if (
not isinstance(ids, list)
or any(not isinstance(value, str) or not value for value in ids)
or type(count) is not int
or count != len(set(ids))
):
return False
return (
bool(row.get("sources"))
and set(row["sources"]) <= message_ids
and started <= created <= datetime.now(timezone.utc)
)
except (KeyError, ValueError, TypeError):
return False
async def cleanup(http, state, *, timeout=60.0, interval=1.0) -> bool:
"""Retain shared/uncertain records; never use unsupported step/skill deletion."""
if state.data.get("pending"):
raise RuntimeError("Unresolved write; reconcile its outcome before cleanup")
conversation_id = state.data.get("conversation_id")
resources = state.data["resources"]
if not conversation_id or conversation_id not in resources.get("conversation", {}):
raise RuntimeError("No recorded conversation owned by this run")
# A previous preflight cannot authorize a later deletion. Ownership and
# neighboring sources may have changed while a failed run was interrupted.
for entry in resources.get("entity", {}).values():
if entry.get("status") != "deleted":
entry["owned"] = False
state.data["cleanup_complete"] = False
state.save()
route = f"conversations/{quote(conversation_id, safe='')}"
response = await http.get(route)
owned_messages = set(resources.get("message", {}))
conversation = resources["conversation"][conversation_id]
if response.status_code != 404:
response.raise_for_status()
metadata = response.json()
expected_metadata = conversation.get("metadata", {})
if (
expected_metadata.get("tutorialRun") != state.data["run_token"]
or metadata.get("id") != conversation_id
or metadata.get("metadata") != expected_metadata
):
raise RuntimeError("Conversation ownership mismatch; no cleanup attempted")
history = await request_json(http, "GET", route + "/messages")
messages = history.get("messages") if isinstance(history, dict) else history
if not isinstance(messages, list) or {m.get("id") for m in messages} != owned_messages:
raise RuntimeError("Unrecorded or missing messages; no cleanup attempted")
await wait_for_extraction(
http, conversation_id, len(owned_messages), timeout=timeout, interval=interval
)
rows = await query(http, PROVENANCE_QUERY, {"message_ids": sorted(owned_messages)})
candidates = {
r["id"] for r in rows if exclusively_owned(r, owned_messages, state.data["started_at"])
}
while True:
exclusive = {
r["id"]
for r in rows
if r["id"] in candidates
and isinstance(r.get("neighbors"), list)
and set(r["neighbors"]) <= owned_messages | candidates
}
if exclusive == candidates:
break
candidates = exclusive
for row in rows:
# A graph edge to data outside this run prevents entity deletion too.
owned = row["id"] in candidates
state.record("entity", row["id"], status="retained", owned=owned, provenance=row)
state.data["cleanup_preflight_complete"] = True
state.save()
elif not state.data.get("cleanup_preflight_complete"):
raise RuntimeError(
"Conversation disappeared before provenance capture; inspect retained IDs"
)
for entity_id, entry in resources.get("entity", {}).items():
entity_route = "entities/" + quote(entity_id, safe="")
if entry.get("status") == "deleted" or not entry.get("owned"):
readback = await http.get(entity_route)
if readback.status_code == 404:
entry["status"] = "deleted"
entry["disposition"] = "absence independently verified"
state.save()
continue
readback.raise_for_status()
entry["status"] = "retained"
entry["owned"] = False
if not entry.get("owned"):
entry["disposition"] = "retained: shared or insufficient ownership evidence"
state.save()
continue
await delete_and_verify(http, entity_route)
entry["status"] = "deleted"
state.save()
await delete_and_verify(http, route)
conversation["status"] = "deleted"
state.save()
ids = [
conversation_id,
*owned_messages,
*(
key
for key, value in resources.get("entity", {}).items()
if value["status"] == "deleted"
),
*(
key
for kind, entries in resources.items()
if kind not in {"conversation", "message", "entity"}
for key in entries
),
]
rows = await query(http, RESIDUAL_QUERY, {"ids": ids, "conversation": conversation_id})
state.data["remaining_rows"] = rows
remaining_ids = {row.get("id") for row in rows}
for entries in resources.values():
for resource_id, entry in entries.items():
if resource_id in remaining_ids:
entry["status"] = "retained"
entry["disposition"] = "present in final exact-ID readback"
for message_id, entry in resources.get("message", {}).items():
if message_id not in remaining_ids:
entry["status"] = "deleted"
retained = {
kind: [key for key, value in entries.items() if value["status"] != "deleted"]
for kind, entries in resources.items()
}
state.data["retained_resources"] = {kind: ids for kind, ids in retained.items() if ids}
state.save()
# Steps/skills can remain attached to the deleted conversation. These are
# recorded dispositions, not a successful assertion that every row vanished.
unsupported = {
key
for kind, entries in resources.items()
if kind not in {"conversation", "message", "entity"}
for key in entries
}
if any(row.get("id") not in unsupported for row in rows):
raise RuntimeError("Unexpected run-owned residuals remain; inspect state")
state.data["cleanup_complete"] = not state.data["retained_resources"]
state.save()
print("Verified: owned conversation and proven-owned entities are absent")
print(f"Retained resources: {state.data['retained_resources']}")
return not state.data["retained_resources"]
Open the helper below and save its complete source as hosted_tutorial_helpers.py in ~/agent-memory-tutorials.
Show hosted_tutorial_helpers.py
hosted_tutorial_helpers.py"""Bounded control flow shared by the hosted documentation exercises.
These helpers know only the documented run outcomes and SDK ontology methods.
They do not contact a service at import time.
"""
from __future__ import annotations
import asyncio
import json
import time
from collections.abc import Awaitable, Callable
from contextlib import asynccontextmanager
from typing import Any
async def poll_skill_run(
fetch: Callable[[], Awaitable[dict[str, Any]]],
*,
timeout: float = 120.0,
interval: float = 2.0,
clock: Callable[[], float] = time.monotonic,
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
) -> dict[str, Any]:
"""Wait through queued/running; return a documented outcome or fail closed."""
if timeout <= 0 or interval <= 0:
raise ValueError("timeout and interval must be positive")
deadline = clock() + timeout
while True:
remaining = deadline - clock()
if remaining <= 0:
raise TimeoutError("Skill run did not settle before the tutorial deadline")
run = await asyncio.wait_for(fetch(), timeout=remaining)
outcome = run.get("outcome")
if outcome in {"Created", "Withheld", "Failed"}:
if outcome == "Created" and not run.get("skillId"):
raise RuntimeError("Created run is missing its returned skillId")
return run
if outcome is not None or run.get("status") not in {"queued", "running"}:
raise RuntimeError(f"Unrecognized skill run state: {run!r}")
await sleep(min(interval, max(0.0, deadline - clock())))
def ontology_binding(active):
"""Capture authoritative IDs, mode and schema; never infer a latest revision."""
values = {
"version_id": active.version_id,
"ontology_id": active.ontology_id,
"revision": active.revision,
"validation_mode": active.validation_mode,
}
if (
not values["version_id"]
or not values["ontology_id"]
or type(values["revision"]) is not int
or values["revision"] < 1
or values["validation_mode"] not in {"permissive", "strict"}
):
raise RuntimeError("No restorable authoritative active binding; no ontology was changed")
values["document"] = active.document.model_dump(mode="json")
return values
async def read_active_binding(http):
"""Read a restorable binding directly from the public REST response.
SDK 0.7.0 get_active() reports the bound version but returns None metadata
when a response has no version record (releases before 0.7.0 inferred it
from the latest revision). This reader requires the actual binding returned
by /ontologies/active, validated against the released public document
model, and fails closed rather than substituting a list lookup.
"""
from neo4j_agent_memory.nams import OntologyDocument
response = await http.get("ontologies/active")
response.raise_for_status()
payload = response.json()
version = payload.get("version") if isinstance(payload, dict) else None
if (
not isinstance(version, dict)
or any(
not isinstance(version.get(key), str) or not version[key].strip()
for key in ("id", "ontology_id")
)
or type(version.get("revision")) is not int
or version["revision"] < 1
or version.get("validation_mode") not in {"permissive", "strict"}
):
raise RuntimeError("No restorable authoritative active binding; no ontology was changed")
def document(value):
if isinstance(value, str):
value = json.loads(value)
return OntologyDocument.model_validate(value).model_dump(mode="json")
try:
active_document = document(payload.get("ontology"))
version_document = version.get("schema_json")
if version_document is not None and document(version_document) != active_document:
raise ValueError("Active and version schemas differ")
except (ValueError, TypeError) as exc:
raise RuntimeError("Invalid or conflicting active ontology schema; retained state") from exc
return {
"version_id": version["id"],
"ontology_id": version["ontology_id"],
"revision": version["revision"],
"validation_mode": version["validation_mode"],
"document": active_document,
}
def _version_binding(version):
from types import SimpleNamespace
if version.document is None:
raise RuntimeError("Returned version has no inspectable schema; retained clone")
return ontology_binding(
SimpleNamespace(
version_id=version.id,
ontology_id=version.ontology_id,
revision=version.revision,
validation_mode=version.validation_mode,
document=version.document,
)
)
async def _require_binding(read_active, expected):
actual = await read_active()
if actual != expected:
raise RuntimeError("Active binding changed or readback differs; retained state and clone")
return actual
async def _clone_absent(ontology, clone_id):
from neo4j_agent_memory.core.exceptions import NotFoundError
try:
await ontology.get(clone_id)
except NotFoundError:
return True
return False
async def verify_ontology_restoration(ontology, state, *, read_active):
"""Fresh, read-only verification of the saved binding and exact clone absence."""
run = state.data.get("ontology", {})
if not run.get("previous"):
raise RuntimeError("State has no captured prior binding")
await _require_binding(read_active, run["previous"])
clone_id = run.get("clone_id")
if not clone_id or not await _clone_absent(ontology, clone_id):
raise RuntimeError("Clone absence is not verified; inspect or recover this run")
if state.data.get("pending"):
raise RuntimeError(
"An operation remains unresolved; run recover before claiming completion"
)
print(f"Verified: original version {run['previous']['version_id']} and mode restored")
print(f"Verified: exercise clone {clone_id} is absent")
async def recover_ontology(ontology, state, *, read_active):
"""Restore only a recognized binding, then delete the proven-owned clone.
An interrupted clone with no returned ID cannot be reconciled automatically.
A concurrent editor's binding is never replaced. There is no server-side
compare-and-swap here, so the exercise requires exclusive ontology editing.
"""
from neo4j_agent_memory.core.exceptions import NotFoundError
run = state.data.get("ontology", {})
previous = run.get("previous")
if not previous:
raise RuntimeError("State has no captured prior binding; no recovery writes were made")
current = await read_active()
if current != previous and current != run.get("strict"):
raise RuntimeError("Active binding changed unexpectedly; retained state and clone")
clone_id = run.get("clone_id")
pending = state.data.get("pending")
if not clone_id:
if pending:
raise RuntimeError(
"Clone outcome is uncertain with no returned ID; inspect retained state"
)
# A prerequisite failed before the first write.
print("Verified: original binding unchanged; no clone was created")
return
owned = state.data["resources"].get("ontology", {}).get(clone_id, {})
if owned.get("created_by_run") is not True:
raise RuntimeError("Clone ownership is not established; no recovery writes were made")
if pending:
if not isinstance(pending, dict) or pending.get("operation") not in {
"clone",
"update",
"activate",
"restore",
"delete",
}:
raise RuntimeError("Unrecognized pending operation; inspect retained state")
# Keep uncertainty recorded until deleting the known clone resolves all
# its revisions. Never replay a clone or an update with an unknown result.
run.setdefault("interrupted_operations", []).append(pending)
state.finish()
if current != previous:
state.begin({"operation": "restore", "version_id": previous["version_id"]})
await ontology.activate(previous["version_id"])
await _require_binding(read_active, previous)
state.finish()
else:
await _require_binding(read_active, previous)
run["restored"] = True
state.save()
print(f"Restored active version: {previous['version_id']} ({previous['validation_mode']})")
# Recheck immediately before deletion. This detects observed concurrent
# changes; it cannot make an unversioned service operation atomic.
await _require_binding(read_active, previous)
if not await _clone_absent(ontology, clone_id):
state.begin({"operation": "delete", "ontology_id": clone_id})
try:
await ontology.delete(clone_id)
except NotFoundError:
pass # A retry still requires the independent GET below.
if not await _clone_absent(ontology, clone_id):
raise RuntimeError(f"Deletion not verified; retained clone {clone_id}")
state.finish()
state.record("ontology", clone_id, status="deleted")
for version_id, entry in state.data["resources"].get("ontology_version", {}).items():
if entry.get("ontology_id") == clone_id:
entry["status"] = "deleted_with_ontology"
run["deleted"] = True
run["status"] = "complete"
state.save()
print(f"Deleted exercise clone: {clone_id} (absence verified)")
@asynccontextmanager
async def temporary_strict_ontology(ontology: Any, template: str, state, *, read_active):
"""Persist the restoration target before mutation and retain failed recovery."""
if state.data.get("ontology") or state.data.get("pending"):
raise RuntimeError("Ontology exercise already started; inspect or recover its state")
previous = await read_active()
catalog = await ontology.list()
if not any(item.name == template and item.is_system for item in catalog):
raise RuntimeError("Required system template is absent; no ontology was changed")
existing_ids = {item.id for item in catalog}
state.data["ontology"] = {"previous": previous, "template": template, "status": "started"}
state.save()
print(f"Previous active version: {previous['version_id']} ({previous['validation_mode']})")
try:
state.begin({"operation": "clone", "template": template})
clone = await ontology.clone(template)
run = state.data["ontology"]
run["clone_id"] = clone.ontology_id
state.record(
"ontology", clone.ontology_id, created_by_run=clone.ontology_id not in existing_ids
)
state.record("ontology_version", clone.id, ontology_id=clone.ontology_id)
state.finish()
if clone.ontology_id in existing_ids:
raise RuntimeError(
"Clone response identifies a preexisting ontology; ownership is unknown"
)
if clone.document is None:
raise RuntimeError("Clone has no inspectable schema")
print(f"Exercise clone: {clone.ontology_id}")
state.begin({"operation": "update", "ontology_id": clone.ontology_id})
strict = await ontology.update(clone.ontology_id, clone.document, validation_mode="strict")
state.record("ontology_version", strict.id, ontology_id=strict.ontology_id)
run["strict"] = _version_binding(strict)
state.finish()
if strict.ontology_id != clone.ontology_id or strict.validation_mode != "strict":
raise RuntimeError("Strict revision response differs from the requested clone and mode")
await _require_binding(read_active, previous)
state.begin({"operation": "activate", "version_id": strict.id})
await ontology.activate(strict.id)
await _require_binding(read_active, run["strict"])
state.finish()
print(f"Activated strict version: {strict.id}")
yield strict
except BaseException as exercise_error:
state.data["ontology"]["exercise_error"] = type(exercise_error).__name__
state.save()
try:
await recover_ontology(ontology, state, read_active=read_active)
except BaseException as recovery_error:
raise recovery_error from exercise_error
raise
else:
await recover_ontology(ontology, state, read_active=read_active)
skills_quickstart.py"""Distil a synthetic procedure; keep review, publication, and disposition explicit."""
from __future__ import annotations
import argparse
import asyncio
import io
import json
from pathlib import Path
from urllib.parse import quote
from zipfile import ZipFile
from hosted_tutorial_cleanup import (
PROVENANCE_QUERY,
cleanup,
exclusively_owned,
query,
request_json,
)
from hosted_tutorial_helpers import poll_skill_run
from hosted_tutorial_state import (
TutorialState,
digest,
http_client,
private_json,
remember_message,
verify_messages,
)
STATE = Path(".tutorial-state/skills.json")
def output_path(state, name):
return state.path.parent / name
async def preflight(http):
capabilities = await request_json(http, "GET", "skills/capabilities")
if capabilities.get("distillation") is not True:
raise RuntimeError("Skill distillation is unavailable; no fixture was seeded")
return capabilities
async def seed(client, state):
"""The CLI establishes the empty-workspace/owner prerequisite before this call."""
name = "docs-skill-" + state.data["run_token"]
metadata = {"tutorialRun": state.data["run_token"], "tutorialLesson": "skills"}
state.begin("create Skills fixture conversation")
conversation = await client.short_term.create_conversation(name, metadata=metadata)
conversation_id = str(conversation.id)
state.data["conversation_id"] = conversation_id
state.record("conversation", conversation_id, metadata=metadata)
state.finish()
state.begin("store Skills fixture messages")
messages = await client.short_term.bulk_add_messages(
conversation_id,
[
{
"role": "user",
"content": "Fixture procedure: look up the order, check the return policy, record a refund decision. All orders and actions below are simulated.",
},
{
"role": "assistant",
"content": "Each fixture uses a delivered order, a confirmed damaged item, and a return window of 30 days. Record the decision without issuing a payment.",
},
],
)
for message in messages:
remember_message(state, message)
state.finish()
if len(messages) != 2:
raise RuntimeError("Expected two fixture messages; inspect the partial seed")
for order in ("FIXTURE-104", "FIXTURE-105", "FIXTURE-106"):
# NAMS start/complete_trace group steps locally; they create no durable trace ID.
trace = await client.reasoning.start_trace(
session_id=conversation_id, task=f"Simulated refund decision for {order}"
)
for tool, arguments, result in [
("lookup_order", {"order_id": order}, {"delivered": True, "days_since_delivery": 4}),
(
"check_return_policy",
{"damage_confirmed": True, "return_window_days": 30},
{"eligible": True},
),
(
"record_refund_decision",
{"order_id": order},
{"decision": "approve", "payment_issued": False},
),
]:
state.begin(f"record step {order}/{tool}")
step = await client.reasoning.add_step(
trace.id, action=tool, observation=json.dumps(result)
)
state.record("step", str(step.id), conversation_id=conversation_id)
state.finish()
state.begin(f"record tool call for {step.id}")
call = await client.reasoning.record_tool_call(
step.id, tool_name=tool, arguments=arguments, result={**result, "simulated": True}
)
state.record("tool_call", str(call.id), step_id=str(step.id))
state.finish()
await client.reasoning.complete_trace(
trace.id, outcome="Simulated decision recorded", success=True
)
await verify_messages(client, state)
traces = await client.reasoning.get_session_traces(conversation_id)
steps = [step for trace in traces for step in trace.steps]
actual_steps = {str(step.id) for step in steps}
actual_calls = {str(call.id) for step in steps for call in step.tool_calls}
if actual_steps != set(state.data["resources"].get("step", {})) or len(actual_steps) != 9:
raise RuntimeError("Step readback differs from the nine recorded fixture IDs")
if actual_calls != set(state.data["resources"].get("tool_call", {})) or len(actual_calls) != 9:
raise RuntimeError("Tool-call readback differs from the nine recorded fixture IDs")
state.data["seed_verified"] = True
state.save()
print(f"Verified fixture: conversation {conversation_id}; 9 recorded steps and 9 tool calls")
def provenance_source_ids(value):
"""Accept explicit source-ID fields; fail closed on undocumented/empty shapes.
The published provenance response is free-form JSON. This conservative
inspector does not infer IDs from arbitrary strings or a grounding score.
An unrecognized live shape needs a reviewed parser update before publication.
"""
found = set()
def visit(node):
if isinstance(node, list):
for item in node:
visit(item)
elif isinstance(node, dict):
for key, item in node.items():
normalized = key.replace("_", "").lower()
if normalized in {"sourceid", "sourcenodeid"}:
if not isinstance(item, str) or not item:
raise RuntimeError("Malformed source ID in provenance")
found.add(item)
elif normalized in {"sourceids", "sourcenodeids"}:
if not isinstance(item, list) or not all(
isinstance(x, str) and x for x in item
):
raise RuntimeError("Malformed source IDs in provenance")
found.update(item)
elif normalized.startswith("source"):
raise RuntimeError(
"Unrecognized source field in provenance; review its schema before publishing"
)
else:
visit(item)
visit(value)
if not found:
raise RuntimeError(
"No recognized source IDs in provenance; inspect response before publishing"
)
return found
async def inspect_provenance(http, state, skill_id):
state.data["provenance_verified"] = False
state.save()
detail = await request_json(http, "GET", f"skills/{quote(skill_id, safe='')}")
provenance = await request_json(
http, "GET", f"skills/{quote(skill_id, safe='')}/explain-provenance"
)
report = {"skill": detail, "provenance": provenance}
path = output_path(state, "skills-review.json")
private_json(path, report)
sources = provenance_source_ids(provenance)
known = {
key
for kind in ("message", "step", "tool_call")
for key in state.data["resources"].get(kind, {})
}
rows = await query(http, PROVENANCE_QUERY, {"message_ids": sorted(known)})
for row in rows:
if exclusively_owned(row, known, state.data["started_at"]):
state.record("entity", row["id"], provenance=row, owned=False)
known.add(row["id"])
if not sources <= known:
state.data["provenance_verified"] = False
state.data["unexplained_sources"] = sorted(sources - known)
state.save()
raise RuntimeError("Provenance references unrecorded sources; publication is blocked")
state.data["provenance_verified"] = True
state.data["review_sha256"] = digest(json.dumps(report, sort_keys=True))
state.data["provenance_source_ids"] = sorted(sources)
state.save()
print(f"Inspect {path} before running publish; generated name is only a naming hint")
return report
async def run_command(http, command, state):
data = state.data
if command == "generate":
if not data.get("seed_verified"):
raise RuntimeError("Run seed successfully before generating")
if data.get("run_id"):
raise RuntimeError("A run already exists; inspect it instead of generating again")
await preflight(http)
state.begin("generate a workspace-scoped skill")
run = await request_json(
http,
"POST",
"skills/generate",
json={"nameHint": "simulated-refund-decision", "scope": {"type": "workspace"}},
)
if not run.get("runId"):
raise RuntimeError("Generation response is missing runId; retain uncertain outcome")
data["run_id"] = run["runId"]
state.record("skill_run", run["runId"])
state.finish()
print(f"Saved run ID: {run['runId']}; run inspect next")
return
if command == "inspect":
run_id = data.get("run_id")
if not run_id:
raise RuntimeError(
"No recorded run ID; inspect local state before any generation retry"
)
async def fetch():
return await request_json(http, "GET", f"skills/runs/{quote(run_id, safe='')}")
run = await poll_skill_run(fetch)
data["run"] = run
state.save()
if run["outcome"] != "Created":
raise RuntimeError(f"No skill to publish: {json.dumps(run)}")
data["skill_id"] = run["skillId"]
state.record("skill", run["skillId"])
await inspect_provenance(http, state, run["skillId"])
return
skill_id = data["skill_id"]
route = "skills/" + quote(skill_id, safe="")
if command == "publish":
if not data.get("provenance_verified") or data.get("published"):
raise RuntimeError("Inspect provenance first; do not repeat publication")
reviewed_hash = data["review_sha256"]
await inspect_provenance(http, state, skill_id)
if data["review_sha256"] != reviewed_hash:
data["provenance_verified"] = False
state.save()
raise RuntimeError(
"Skill or provenance changed; inspect the new report before publication"
)
state.begin("approve reviewed skill")
response = await http.post(route + "/review", json={"decision": "approve"})
response.raise_for_status()
data["approved"] = True
state.finish()
state.begin("publish reviewed skill")
response = await http.post(route + "/publish")
response.raise_for_status()
data["published"] = True
state.finish()
print(f"Published skill ID: {skill_id}")
elif command == "download":
if not data.get("published"):
raise RuntimeError("Publish the reviewed skill first")
verification = await request_json(http, "GET", route + "/verify")
private_json(output_path(state, "skills-attestation.json"), verification)
response = await http.get(route + "/download")
response.raise_for_status()
with ZipFile(io.BytesIO(response.content)) as archive:
if not any(Path(name).name == "SKILL.md" for name in archive.namelist()):
raise RuntimeError("Downloaded archive has no SKILL.md")
path = output_path(state, "simulated-refund-skill.zip")
# Private directory; create the artifact privately too, without extraction.
import os
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "wb") as output:
output.write(response.content)
print(f"Verified ZIP contains SKILL.md; saved {path}")
print("Inspect skills-attestation.json; ZIP validation does not verify its signature")
async def main(argv=None):
from neo4j_agent_memory import NamsSettings, connect
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"command",
choices=["seed", "generate", "inspect", "publish", "download", "state", "cleanup"],
)
parser.add_argument("--state", type=Path, default=STATE)
parser.add_argument(
"--workspace-label", help="Operator-recorded workspace name/ID; not routing"
)
parser.add_argument(
"--empty-workspace-confirmed",
action="store_true",
help="Owner verified this newly provisioned workspace is empty",
)
parser.add_argument(
"--disposal-owner", help="Workspace owner/operator responsible for retained resources"
)
parser.add_argument(
"--disposal-plan", help="Verified disposal procedure or explicitly agreed retention"
)
args = parser.parse_args(argv)
settings = NamsSettings()
if args.command == "seed":
if not args.empty_workspace_confirmed or not args.disposal_owner or not args.disposal_plan:
parser.error(
"seed requires --empty-workspace-confirmed, --disposal-owner and --disposal-plan"
)
state = TutorialState.create(
args.state,
settings,
"skills",
workspace_label=args.workspace_label,
workspace_owner=args.disposal_owner,
)
state.data["disposition_agreement"] = {
"owner": args.disposal_owner,
"procedure": args.disposal_plan,
"owner_verified_empty_workspace": True,
}
state.save()
async with http_client(settings) as http:
state.data["capabilities"] = await preflight(http)
state.save()
client = await connect(settings)
try:
await seed(client, state)
finally:
await client.close()
return
state = TutorialState.load(args.state, settings, "skills")
if args.command == "state":
print(json.dumps(state.inspect(), indent=2))
return
async with http_client(settings) as http:
if args.command == "cleanup":
if state.data.get("run_id") and state.data.get("run", {}).get("outcome") not in {
"Created",
"Withheld",
"Failed",
}:
raise RuntimeError(
"Skill run is not recorded terminal; inspect the same run or hand it to the "
"owner before cleanup. A client timeout does not cancel the service job."
)
if state.data.get("run", {}).get("outcome") == "Created":
skill_id = state.data["run"].get("skillId")
if (
not skill_id
or skill_id != state.data.get("skill_id")
or skill_id not in state.data["resources"].get("skill", {})
):
raise RuntimeError(
"Created skill ID is not fully recorded; inspect the same run before cleanup"
)
complete = await cleanup(http, state)
print("Operator disposition is still required for retained step/tool/run/skill records")
if not complete:
raise SystemExit(2)
else:
await run_command(http, args.command, state)
if __name__ == "__main__":
asyncio.run(main())
Before the first service operation, check the assembled files offline:
python -m py_compile \
hosted_tutorial_state.py \
hosted_tutorial_cleanup.py \
hosted_tutorial_helpers.py \
skills_quickstart.py
python skills_quickstart.py --help
Expected: compilation prints nothing and exits successfully; --help prints
the command choices. Compilation catches syntax errors; the CLI check also
loads the local imports. Neither command authenticates, contacts NAMS or
creates tutorial state. Fix a missing or truncated file before continuing.
Replace the owner and disposition text below with the actual arrangement you
verified. Supply --empty-workspace-confirmed only after that owner has checked
the newly provisioned workspace:
python skills_quickstart.py seed \
--state .tutorial-state/skills.json \
--workspace-label 'workspace name or ID from the dashboard' \
--empty-workspace-confirmed \
--disposal-owner 'the responsible workspace owner' \
--disposal-plan 'the verified disposal procedure or explicitly agreed retention'
Expected: exact readback of the two messages, followed by Verified fixture:
conversation …; 9 recorded steps and 9 tool calls. The step and tool-call IDs
must match the saved fixture IDs, not merely an aggregate count.
The private .tutorial-state/skills.json retains the owner arrangement,
endpoint/workspace/credential identity, returned IDs and partial operation
status. A changed identity or existing state file blocks accidental reseeding.
If a write fails, retain the state and inspect it with:
python skills_quickstart.py state --state .tutorial-state/skills.json
Keep .tutorial-state/ out of version control. The state command displays
saved information without running generation or publication.
Step 2: Generate and poll with real IDs
python skills_quickstart.py generate --state .tutorial-state/skills.json
python skills_quickstart.py inspect --state .tutorial-state/skills.json
Generation submits nameHint and workspace scope, then saves the actual
runId. A hint is not a promise that the service will use exactly that name.
The inspect command polls that run for at most two minutes, with bounded HTTP
requests. Queued/running states remain pending. Created must contain a real
skillId; Withheld and Failed stop without publication. Missing IDs,
unknown states and request failures are errors, not successful generation.
Expected on creation: a private .tutorial-state/skills-review.json containing
the skill detail and provenance. A duplicate generate command is refused. If a
run is withheld, inspect its recorded result and stop the publication path;
do not broaden the scope or invent a skill ID.
Choose the next action from the observed result, not from the next numbered heading. A two-minute client timeout does not cancel the service job.
| Outcome | Next action |
|---|---|
|
Review |
|
Run |
Timeout; job remains queued/running |
Repeat |
Unknown state or unrecognized/unexplained provenance |
Keep the saved state and any review report for maintainer/operator inspection. Do not bypass the parser or publish. |
Interrupted approval/publication |
Inspect local state and have the owner reconcile the saved skill ID. Do not blindly repeat |
To resume observation after a timeout:
python skills_quickstart.py inspect --state .tutorial-state/skills.json
A recorded Withheld or Failed outcome still demonstrates submitting and
observing a job; it does not demonstrate a generated or published skill. The
remaining review/publish/download milestones are skipped. Cleanup refuses a
saved run whose terminal outcome has not been observed: first inspect that
same run or arrange operator handoff for the still-running job. Neither a
client exit nor conversation deletion establishes that the service job ended.
Step 3: Inspect the procedure and evidence
python -m json.tool .tutorial-state/skills-review.json
Check that the generated procedure is limited to the simulated three-step workflow, carries the return-window and damage conditions, and never claims a payment was issued. Compare its reported source IDs with the recorded fixture messages, steps, tool calls and proven derived entities. Provenance makes the result reviewable; it is not a truth guarantee.
The program blocks publication when sources are unexplained. The service’s provenance response is free-form JSON: this inspector accepts explicit source-ID fields and stops if their shape is unrecognized or empty. Such a response needs inspection and a reviewed parser update, not an automatic bypass or a claim that live provenance passed.
Do not publish if the procedure misstates the fixture or needs further review.
Step 4: Publish only the reviewed result
python skills_quickstart.py publish --state .tutorial-state/skills.json
This command rechecks the saved skill and provenance. If they changed since
inspection, it saves the new report and stops with
Skill or provenance changed; inspect the new report before publication.
Review the new skills-review.json as in Step 3, then run the Step 2
inspect command again before rerunning publish:
python skills_quickstart.py inspect --state .tutorial-state/skills.json
python skills_quickstart.py publish --state .tutorial-state/skills.json
A second publish without that inspection is refused. When nothing changed,
publish submits approval and publication for the saved skillId.
Expected: Published skill ID: followed by that ID.
Publication is deliberately separate from reading or generating a draft. An interrupted approval/publication retains pending state; do not repeat it blindly or delete the ledger to bypass the recovery check.
Step 5: Download and inspect the package
python skills_quickstart.py download --state .tutorial-state/skills.json
python -m zipfile -l .tutorial-state/simulated-refund-skill.zip
python -m json.tool .tutorial-state/skills-attestation.json
Expected: Verified ZIP contains SKILL.md; saved followed by the path to
.tutorial-state/simulated-refund-skill.zip, then
Inspect skills-attestation.json; ZIP validation does not verify its signature,
then the archive listing and the attestation JSON.
The program saves the ZIP privately and inspects its members without extracting
arbitrary paths.
Inspect attestation separately: ZIP validity does not prove a valid signature.
attestationConfigured: false means signing is not configured; an unsigned
publication must not be described as a verified signed artifact. Download,
package structure and attestation are separate outcomes. The package is neither
loaded into an agent nor executed.
Step 6: Clean up supported records and retain the disposition
python skills_quickstart.py cleanup --state .tutorial-state/skills.json
printf 'Cleanup exit status: %s\n' "$?"
python skills_quickstart.py state --state .tutorial-state/skills.json
The helper checks and deletes only the owned conversation and proven-owned entities through supported routes, verifying their absence. It reports retained steps, tool calls, skill runs and skills separately, including partial or published artifacts. Shared or uncertain entities also remain recorded.
Expected output distinguishes Verified: owned conversation and proven-owned
entities are absent from Retained resources: and the outstanding operator
disposition. Cleanup exits with status 2 when any resource is retained, even
when the supported deletions passed; this is not an empty-workspace result. Keep
the private state, review files and archive until the named owner has verified
disposal or recorded the agreed retention of every remaining resource. Do not
invent a delete route or reset the workspace to make a cleanup result look
complete.
Illustrative disposition after a terminal generated run (placeholder IDs):
Verified: owned conversation and proven-owned entities are absent
Retained resources: {'step': ['<step-id>', '...'], 'tool_call': ['<tool-id>', '...'], 'skill_run': ['<run-id>'], 'skill': ['<skill-id>']}
Operator disposition is still required for retained step/tool/run/skill records
Cleanup exit status: 2
A Withheld run has no created skill ID, but its recorded steps, tool calls and
run still require disposition. The complete ID lists are in the state file;
… above only shortens this illustration. This expected exit 2 is not an
empty-workspace pass and is not a reason to reseed.
Inspect locally after a credential change
If the original key expires or is rotated, normal lesson commands keep refusing to use its saved IDs with another credential. Use this separate local command:
python hosted_tutorial_state.py inspect-file .tutorial-state/skills.json
It needs no key and makes no service requests. The summary omits credential
fingerprints, message digests, fixture metadata and review/provenance payloads.
It still contains resource IDs and operator labels, so keep it private. Its
local_only: true and service_checked: false mean it reports saved evidence,
not current remote state or authorization.
Give the authorized owner the workspace label, endpoint, run token, exact resource IDs and pending operation through your agreed private channel. The owner must establish the actual workspace and reconcile those resources with a valid credential. Do not edit the identity hash, substitute a key in the ledger, or remove state to unlock a write. The helper cannot migrate a run to a new key or supply an administrative deletion route.
Keep the old run and start a separate exercise
See Recover from an interrupted tutorial run to check the recorded state before reseeding.
A normal Skills run retains steps, tool calls and possibly a run/skill. Its cleanup does not make the workspace empty. Another Skills exercise requires a newly provisioned, owner-verified empty workspace and a new disposition agreement; preserve the first workspace’s ledger and reports. The automatic check below does not approve starting over with retained resources.
For a prerequisite failure before the first remote write, the new ledger’s
remote_write_started: false, empty resources and absent pending operation
establish that this program did not attempt a mutation. An empty ledger alone
is not enough. Older empty ledgers without that marker, a lost returned ID, or any
uncertain write fail the check and require owner inspection.
After correcting the prerequisite and repeating the empty-workspace/owner
checks, choose this unused run directory. The first command only reads the old ledger and checks the new path. It preserves
the original file and creates nothing. && prevents seed if that check fails:
python hosted_tutorial_state.py check-new-run .tutorial-state/skills.json \
--next-state .tutorial-state/skills-run-2/state.json &&
python skills_quickstart.py seed --state .tutorial-state/skills-run-2/state.json \
--workspace-label 'workspace name or ID from the dashboard' \
--empty-workspace-confirmed \
--disposal-owner 'the responsible workspace owner' \
--disposal-plan 'the verified disposal procedure or explicitly agreed retention'
Expected before seed: Recorded new-run check: no_remote_write_started for
an unstarted run, or recorded_disposition_complete for fully disposed records.
This is a check of saved evidence, not a new service verification. The seed
still checks its own prerequisites and refuses an existing target state file.
Use the new --state path for every later command; reports and archives are
written alongside that new state. Never copy the old IDs into the new ledger.