Exploded engineering blueprint of a delivery robot

schematic sheet 01 — hoodly technical documentation

Python SDK

The official client for robots and fleet software. Zero runtime dependencies, fully typed, and designed around the one thing that actually goes wrong in the field: the network disappearing halfway through a request.

Install

Python 3.10 or newer. The floor is 3.10 because that is what ROS 2 Humble ships, and Humble is the oldest distribution the ROS 2 package supports.

shell
pip install hoodly

Nothing else comes with it. No requests, no pydantic, no transitive resolver conflicts — the transport is built on the standard library, because this installs onto industrial PCs and into ROS 2 workspaces where a dependency clash is a real cost.

Five lines

The API key comes from HOODLY_API_KEY if you do not pass one.

quickstart.py
from hoodly import HoodlyClient

client = HoodlyClient()  # reads HOODLY_API_KEY

verification = client.prove(
    "Dock inspection, bay 7",
    {"dock": 7, "payload_kg": 12.5, "defects_found": 0},
)

print(verification.tx_hash)
print(f"https://hoodly.fun{verification.share_url}")

prove() is the shortcut: it claims a task and completes it in one call. When you need the task id in between — to log it, or because claiming and finishing are minutes apart — use the two steps directly.

claim, then complete
task = client.create_task("Dock inspection, bay 7")
# ... the robot does the work ...
verification = client.complete_task(task.id, {"dock": 7, "defects_found": 0})

What complete_task does when the network drops

This is the part worth reading, because it is the difference between a library that works on a desk and one that works on a robot.

A completion request triggers an on-chain transaction. If the connection dies before the response arrives, you cannot know from the client side whether the anchor happened. A naive retry would anchor the same task twice and burn quota and gas for a duplicate.

So complete_task never blindly retries. On a transport failure it reconciles: it polls the task's real state and reports what actually happened.

the three outcomes
from hoodly import (
    HoodlyClient,
    AnchorUnconfirmedError,
    InsufficientCreditError,
    AnchoringFailedError,
)

client = HoodlyClient()

try:
    verification = client.complete_task(task_id, proof)
    print("anchored:", verification.tx_hash)

except AnchorUnconfirmedError as exc:
    # Broadcast, but no receipt in time. It may still land.
    # Do NOT resubmit — poll instead.
    print("unconfirmed:", exc.tx_hash)
    verification = client.wait_for_anchor(exc.task_id, timeout=300)

except InsufficientCreditError as exc:
    # Nothing was sent. Top up, then complete the same task again.
    print("out of credit:", exc.balances)

except AnchoringFailedError:
    # Nothing reached the chain and the credit hold was released.
    # Safe to resubmit as a new task.
    pass

Every failure mode the API can report has its own exception type, because a robot has to react differently to each: a 402 means top up, a 429 means slow down, and an unconfirmed anchor means wait and look.

Never retry an unconfirmed anchor

AnchorUnconfirmedError is the one failure that must not be retried. The transaction is already on the wire; sending the proof again can anchor the same task a second time. Poll wait_for_anchor() or open exc.explorer_url instead. Everything else in this SDK — the offline queue included — follows that same rule.

The offline queue

A mobile robot loses connectivity constantly. Dropping a proof because a WiFi handover happened mid-upload would defeat the point of keeping records, so OfflineQueue writes evidence to a local SQLite file first and uploads it when the network returns. The file survives a reboot and a power cut.

offline_robot.py
from hoodly import HoodlyClient
from hoodly.queue import OfflineQueue

client = HoodlyClient()
queue = OfflineQueue("/var/lib/hoodly/queue.db")

# Never blocks on the network. Returns a local id.
queue.enqueue("Waypoint 7 reached", {"battery_pct": 61.5})

# Call this on a timer. Unfinished work stays queued.
report = queue.flush(client)
print(report.anchored, "anchored,", report.still_pending, "still waiting")

Three properties make it safe to run unattended.

A crash between claiming and anchoring cannot orphan a task. The task id is persisted the moment the claim succeeds, so resuming completes that same task instead of claiming a second one.

A flush stops early instead of hammering a wall. No network, no credit or a rate limit ends the pass, since the next entry would hit the same condition. Order is preserved, so the anchored sequence matches the real one.

A flush is bounded in time. It gives up on an unconfirmed anchor quickly rather than blocking for minutes, because the queue is durable — the next pass reconciles the same task id. That is what makes it usable from a ROS 2 timer or any single-threaded control loop.

When the work happened vs. when it was anchored

For queued proofs these are different moments, sometimes hours apart, and only one of them is the fact being attested. The queue records the event time inside the evidence under hoodly_occurred_at:

what gets anchored
{
  "battery_pct": 61.5,
  "hoodly_occurred_at": "2026-08-06T12:41:07.812+00:00"
}

The chain timestamps the record; the evidence timestamps the event. Pass your own occurred_at to enqueue() if the work finished before it was recorded.

Telemetry

Telemetry is free and separate from proofs — it feeds the live fleet view without consuming anchor credit. Batch it if you sample often.

telemetry
client.send_telemetry(
    battery_pct=61.5,
    metrics={"cpu_pct": 23.0, "lat": 52.52, "lon": 13.405},
)

client.send_telemetry_batch([
    {"battery_pct": 61.5, "recorded_at": "2026-08-06T12:41:07Z"},
    {"battery_pct": 60.9, "recorded_at": "2026-08-06T12:42:07Z"},
])

Verifying without a key

Verification is public, so this call needs no authentication and works for any proof hash — including one from a fleet you do not own. Read checks rather than verified: a summary is exactly what an auditor should not have to trust.

verify
result = client.verify("0x9c22ff5f…")

if result is None:
    print("never anchored")
elif result.failed_checks:
    print("suspect:", result.failed_checks)
else:
    print("verified in block", result.block_number)

Client options

FieldTypeDescription
api_keystr | NoneDefaults to the HOODLY_API_KEY environment variable.
base_urlstrDefaults to https://www.hoodly.fun. Point at a staging deployment if you have one.
timeoutfloatPer-request timeout in seconds. Keep it above the server's anchoring budget so a slow block is not mistaken for a dead connection.
max_retriesintApplies to idempotent reads only. Completion is never retried automatically — it reconciles instead.

Next

If the robot runs ROS 2, do not wire this up by hand — the ROS 2 package wraps this SDK in an action server and collects pose, diagnostics and battery state into the evidence for you.