Exploded engineering blueprint of a delivery robot

schematic sheet 01 — hoodly technical documentation

ROS 2 Integration

Two packages that drop into an existing workspace: an action to prove a completed task, and a node that collects the evidence, anchors it, and keeps everything safe while the robot is out of coverage.

What you get

FieldTypeDescription
hoodly_msgsament_cmakeThe ProveTask action definition — goal, result and feedback.
hoodly_rosament_pythonThe node: action server, evidence collectors, offline queue.

Tested against Humble and Jazzy. The node is plain rclpy with no distro-specific code.

Why an action, not a service

Anchoring is a long-running goal with progress and a result, which is precisely what an action is for. Claiming a task, broadcasting a transaction and waiting for a block receipt takes seconds at best — and on a robot that just reconnected, much longer. A service call would block the caller with no feedback and no way to observe the stages.

Install

The node needs the Python SDK in the same interpreter that runs ROS 2. It is pure standard library, so it adds nothing else to the robot image. The ROS packages themselves are downloaded from this site — always the tree that ships with the current deploy (see /ros2).

shell
pip install hoodly

cd ~/ros2_ws/src
curl -fsSL https://www.hoodly.fun/api/ros2/download -o hoodly-ros2.zip
unzip hoodly-ros2.zip

cd ~/ros2_ws
rosdep install --from-paths src --ignore-src -r -y
colcon build --packages-select hoodly_msgs hoodly_ros
source install/setup.bash

Run

The API key is read from the environment rather than taken as a launch argument: launch arguments end up in process listings and in ros2 param output, and a key that can spend anchor quota does not belong there.

shell
export HOODLY_API_KEY=hdly_your_api_key
ros2 launch hoodly_ros hoodly.launch.py
prove something
ros2 action send_goal --feedback /hoodly/prove_task hoodly_msgs/action/ProveTask \
  "{title: 'Inspect conveyor B4', evidence_json: '{\"defects_found\": 0}'}"

The result carries proof_hash, tx_hash, explorer_url and a share_url you can hand to anyone — the proof page verifies itself against the chain, no account needed.

From your own node

prove_client.py
import json

from rclpy.action import ActionClient
from rclpy.node import Node

from hoodly_msgs.action import ProveTask


class InspectionRunner(Node):
    def __init__(self):
        super().__init__("inspection_runner")
        self._prove = ActionClient(self, ProveTask, "hoodly/prove_task")

    async def finish_inspection(self, defects: int):
        self._prove.wait_for_server()

        goal = ProveTask.Goal()
        goal.title = "Inspect conveyor B4"
        goal.evidence_json = json.dumps({"defects_found": defects})
        # Do not block the behaviour tree on the network.
        goal.queue_only = True

        handle = await self._prove.send_goal_async(goal)
        result = (await handle.get_result_async()).result
        self.get_logger().info(result.message)

Evidence is collected for you

Your evidence_json keys stay at the top level. Everything the node gathers on its own goes under a single ros key, so it can never collide with an application field.

what gets anchored
{
  "defects_found": 0,
  "hoodly_occurred_at": "2026-08-06T12:41:07.812+00:00",
  "ros": {
    "pose": {
      "frame_id": "map",
      "child_frame_id": "base_link",
      "position": { "x": 12.418, "y": -3.902, "z": 0.0 },
      "orientation": { "x": 0.0, "y": 0.0, "z": 0.707107, "w": 0.707107 },
      "yaw_deg": 90.0
    },
    "diagnostics": { "worst_level": "OK", "all_ok": true },
    "battery": { "percent": 61.5, "voltage_v": 24.812 },
    "recorded_by": "hoodly_proof"
  }
}

Pose comes from tf, looked up once at the moment the goal is handled. Diagnostics and battery state come from /diagnostics and /battery_state. Each source is optional at runtime: a missing frame, a missing topic or an absent interface package disables that piece of evidence rather than failing the proof.

Three details in that payload are deliberate. Floats are rounded to six decimals, because raw doubles carry noise far below any sensor's real precision and that noise goes into the hash — two records of the same standstill would otherwise produce different proof hashes for identical facts. Diagnostics are condensed to the worst level plus the names of anything not OK, since a full dump would add hundreds of lines saying "fine" to every proof. hoodly_occurred_at is when the work happened, which for a queued proof is not when it was anchored.

Offline is the normal case

Nothing here depends on being online at the moment of the goal. Evidence is written to a SQLite file before any network call, and a timer uploads it when connectivity returns. The file survives a reboot and a power cut.

For a robot that must never block on the network, set queue_only. The goal returns as soon as the write lands and the flush timer anchors it later.

record now, anchor later
ros2 action send_goal /hoodly/prove_task hoodly_msgs/action/ProveTask \
  "{title: 'Waypoint 7 reached', queue_only: true}"

Feedback reports the current stage and the queue_depth, so a fleet dashboard can show a backlog building up while a robot is in a dead zone.

An unconfirmed anchor is not a failure

If the transaction was broadcast but no receipt arrived in time, the goal succeeds with anchor_unconfirmed: true and a tx_hash. That is an outcome, not an error — and specifically not a signal to send the goal again. The proof may still land, and anchoring the same task twice costs real quota and gas for a duplicate. The node keeps reconciling it on the flush timer.

For the same reason, cancelling a goal is rejected: once a transaction is on the wire it cannot be recalled, and the evidence is already durably queued.

Parameters

FieldTypeDescription
api_keystringEmpty by default, which reads HOODLY_API_KEY from the environment. Prefer that.
base_urlstringDefault https://www.hoodly.fun. Point at staging if you have one.
queue_pathstringDefault ~/.local/share/hoodly/queue.db. Must be writable and persistent — surviving a reboot is the entire point.
flush_interval_sdoubleDefault 30.0. One HTTP request per pass when the queue is empty.
flush_batch_sizeintDefault 10. Upper bound per pass, so a backlog of hundreds cannot occupy the executor in one go.
request_timeout_sdoubleDefault 90.0. Above the server's anchoring budget, so a slow block is not read as a dead connection.
purge_keep_lastintDefault 500. Anchored queue rows kept for inspection before trim.
map_frame / robot_framestringDefault map / base_link. Match your stack.
battery_topic / diagnostics_topicstringDefault /battery_state and /diagnostics.

Threading

The node runs on a MultiThreadedExecutor with anchoring and flushing in their own callback groups. Sharing a group with the subscriptions would mean a slow anchor stops pose and diagnostics updates — and stops the timer that is draining the queue.

Does this need anything beyond the hosted API?

No. The robot talks to the same public HTTP API as any other client, and the entire ROS 2 side runs on the robot. There is no bridge to deploy, no websocket to keep open and no long-lived server involved — the hosted deployment stays exactly as it is.

What a proof does and does not establish

An anchor proves that this exact evidence existed at this block time and was never altered afterwards, and that it was claimed by a specific registered robot. It does not observe the robot. Pose from tf is what the robot believed its pose to be — a strong record for an audit trail, not an independent measurement. How strong the evidence is remains your integration's decision; Hoodly makes it immutable.