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

hosted_tutorial_state.py

Private ledger and local inspection

Reuse unchanged

hosted_tutorial_cleanup.py

Supported scoped cleanup; retained resources stay explicit

Reuse unchanged

hosted_tutorial_helpers.py

Bounded polling and ontology support shared with the preceding lesson

Reuse unchanged

skills_quickstart.py

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
Save as 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
Save as 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
Save as 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)
Save as 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

Created with accepted provenance

Review skills-review.json in step 3; publish only after that review.

Withheld or Failed

Run state to read the recorded outcome, then proceed to step 6 for disposition. Do not publish, regenerate or broaden the scope.

Timeout; job remains queued/running

Repeat inspect with the same state and run ID. It only observes that job; each attempt has the same two-minute bound. If you cannot continue, hand the run ID to the operator.

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 publish or clear its pending operation.

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.