Skill: rotorstar/hitl-protocol (version 0.7.2)

--- name: hitl-protocol description: "HITL Protocol — the open standard for human decisions in autonomous agent workflows. When a website or API needs human input, it returns HTTP 202 with a review URL. Autonomous agents like OpenClaw, Claude, Codex, or Goose forward the URL, poll for the structured result, and continue. Use this skill to make any website agent-ready, or to handle human-in-the-loop responses in your agent. Covers approval, selection, input forms, confirmation, and escalation review types. Supports polling, SSE, webhook transports, channel-native inline buttons (Telegram, Slack, Discord, WhatsApp, Teams), opaque token security, multi-step form wizards, and multi-round edit cycles." license: Apache-2.0 compatibility: - claude - codex - gemini - openclaw - goose - copilot metadata: version: "0.7" spec_url: "https://github.com/rotorstar/hitl-protocol/blob/main/spec/v0.7/hitl-protocol.md" hitl: supported: true types: [approval, selection, input, confirmation, escalation] info: "Teaches the HITL Protocol itself — for building HITL services and HITL-aware agents." --- # HITL Protocol AI agents increasingly visit websites and services on behalf of their users: searching, booking, applying, purchasing. But some decisions are too important to leave to an agent alone — an agent can hallucinate, misinterpret, or make the wrong call at a critical moment — cancel the wrong insurance policy, book a non-refundable trip to the wrong city, wire money to the wrong account. HITL Protocol ("Human in the Loop") is an open standard that ensures humans stay in control of these moments. The user stays in their favorite messenger — Telegram, WhatsApp, Slack — and either taps a decision button right there or follows a link to a web page with a comfortable UI: cards, forms, buttons. No wall of text. No blind agent decisions. When you encounter a HITL response, tell your user that a decision is needed that should not be made by an agent alone — forward the prompt and make it easy to act: show the review link or render action buttons. HITL Protocol is to human decisions what OAuth is to authentication — an open standard connecting **Services**, **Agents**, and **Humans**. When a service needs human input, it returns HTTP 202 with a review URL. The agent forwards the URL to the human. The human opens it in a browser, gets a rich UI, and makes an informed decision. The agent polls for the structured result and continues. For simple decisions (confirm/cancel, approve/reject), agents can render **native messaging buttons** directly in Telegram, Slack, Discord, WhatsApp, or Teams — no browser needed. The service opts in via `submit_url`. **No SDK required. No UI framework mandated. Just HTTP + URL + polling.** **[Interactive Playground](https://rotorstar.github.io/hitl-protocol/playground/index.html)** — try all review types, transports, and inline actions live in your browser. ## Who Are You? | Your role | You want to... | Read | |-----------|----------------|------| | Service/website builder | Add HITL endpoints to your API so agents can request human input | [Service Integration Guide](skills/references/service-integration.md) | | Agent developer | Handle HTTP 202 + HITL responses from services | [Agent Integration Guide](skills/references/agent-integration.md) | | Both / Learning | Understand the full protocol | Continue reading below | ## The Flow ``` Standard flow (all review types): 1. Human → Agent: "Find me jobs in Berlin" 2. Agent → Service: POST /api/search {query: "Senior Dev Berlin"} 3. Service → Agent: HTTP 202 + hitl object (review_url, poll_url, type, prompt) 4. Agent → Human: "Found 5 jobs. Review here: {review_url}" 5. Human → Browser: Opens review_url → rich UI (cards, forms, buttons) 6. Human → Service: Makes selection, clicks Submit 7. Agent → Service: GET {poll_url} → {status: "completed", result: {action, data}} 8. Agent → Human: "Applied to 2 selected jobs." Inline flow (v0.7 — simple decisions only, when submit_url present): 1. Human → Agent: "Send my application emails" 2. Agent → Service: POST /api/send {emails: [...]} 3. Service → Agent: HTTP 202 + hitl object (incl. submit_url, submit_token, inline_actions) 4. Agent → Human: Native buttons in chat: [Confirm] [Cancel] [Details →] 5. Human → Agent: Taps [Confirm] in chat 6. Agent → Service: POST {submit_url} {action: "confirm", submitted_via: "telegram"} 7. Service → Agent: 200 OK {status: "completed"} 8. Agent → Human: Updates message: "Confirmed — 3 emails sent." ``` The agent never renders UI. The service hosts the review page. Sensitive data stays in the browser — never passes through the agent. The inline flow is an optional shortcut for simple decisions. ## Feature Matrix | Feature | Details | |---------|---------| | **Review types** | `approval`, `selection`, `input`, `confirmation`, `escalation` | | **Form field types** | `text`, `textarea`, `number`, `date`, `email`, `url`, `boolean`, `select`, `multiselect`, `range`, custom `x-*` | | **Transport** | Polling (required), SSE (optional), Callback/Webhook (optional) | | **Inline submit** | `submit_url` + native messaging buttons (Telegram, Slack, Discord, WhatsApp, Teams) — service opt-in | | **States** | `pending` → `opened` → `in_progress` → `completed` / `expired` / `cancelled` | | **Security** | Opaque tokens (43 chars, base64url, 256-bit entropy), SHA-256 hash storage, timing-safe comparison, HTTPS only | | **Multi-round** | `previous_case_id` / `next_case_id` for iterative edit cycles (Approval type) | | **Forms** | Single-step fields, multi-step wizard, conditional visibility, validation rules, progress tracking | | **Timeouts** | ISO 8601 duration, `default_action`: `skip` / `approve` / `reject` / `abort` | | **Discovery** | `.well-known/hitl.json`, SKILL.md `metadata.hitl` extension | | **Reminders** | `reminder_at` timestamps, `review.reminder` SSE event | | **Rate limiting** | 60 requests/min per case on poll endpoint, `Retry-After` header | ## Five Review Types | Type | Actions | Multi-round | Form fields | Use case | |------|---------|:-----------:|:-----------:|----------| | **Approval** | `approve`, `edit`, `reject` | Yes | No | Artifact review (CV, email, deployment plan) | | **Selection** | `select` | No | No | Choose from options (job listings, targets) | | **Input** | `submit` | No | Yes | Structured data entry (salary, dates, preferences) | | **Confirmation** | `confirm`, `cancel` | No | No | Irreversible action gate (send emails, deploy) | | **Escalation** | `retry`, `skip`, `abort` | No | No | Error recovery (deployment failed, API error) | ## HITL Object (HTTP 202 Response Body) When a service needs human input, it returns HTTP 202 with this structure: ```json { "status": "human_input_required", "message": "5 matching jobs found. Please select which ones to apply for.", "hitl": { "spec_version": "0.7", "case_id": "review_abc123", "review_url": "https://service.example.com/review/abc123?[REDACTED_SECRET]", "poll_url": "https://api.service.example.com/v1/reviews/abc123/status", "type": "selection", "prompt": "Select which jobs to apply for", "timeout": "24h", "default_action": "skip", "created_at": "2026-02-22T10:00:00Z", "expires_at": "2026-02-23T10:00:00Z", "context": { "total_options": 5, "query": "Senior Dev Berlin" } } } ``` ### Required Fields | Field | Type | Description | |-------|------|-------------| | `spec_version` | `"0.7"` | Protocol version | | `case_id` | string | Unique, URL-safe identifier (pattern: `review_{random}`) | | `review_url` | URL | HTTPS URL to review page with opaque bearer token | | `poll_url` | URL | Status polling endpoint | | `type` | enum | `approval` / `selection` / `input` / `confirmation` / `escalation` / `x-*` | | `prompt` | string | What the human needs to decide (max 500 chars) | | `created_at` | datetime | ISO 8601 creation timestamp | | `expires_at` | datetime | ISO 8601 expiration timestamp | ### Optional Fields | Field | Type | Description | |-------|------|-------------| | `timeout` | duration | How long the review stays open (`24h`, `PT24H`, `P7D`) | | `default_action` | enum | `skip` / `approve` / `reject` / `abort` — action on expiry | | `callback_url` | URL / null | Echoed callback URL if agent provided one | | `events_url` | URL | SSE endpoint for real-time status events | | `context` | object | Arbitrary data for the review page (not processed by agent) | | `reminder_at` | datetime / datetime[] | When to re-send the review URL | | `previous_case_id` | string | Links to prior case in multi-round chain | | `surface` | object | UI format declaration (`format`, `version`) | | `submit_url` | URL | Agent-submit endpoint for channel-native inline buttons (v0.7) | | `submit_token` | string | Bearer token for `submit_url` authentication (required if `submit_url` set) | | `inline_actions` | string[] | Actions permitted via `submit_url` (e.g. `["confirm", "cancel"]`). If absent, all actions for the type are allowed. | ## Poll Response (Completed) ```json { "status": "completed", "case_id": "review_abc123", "completed_at": "2026-02-22T10:15:00Z", "result": { "action": "select", "data": { "selected_jobs": ["job-123", "job-456"], "note": "Only remote positions" } } } ``` The `result` object is present only when `status` is `"completed"`. It always contains `action` (string) and `data` (object with type-dependent content). ### Poll Response Statuses | Status | Terminal | Description | Key fields | |--------|:--------:|-------------|------------| | `pending` | No | Case created, human hasn't opened URL | `expires_at` | | `opened` | No | Human opened the review URL | `opened_at` | | `in_progress` | No | Human is interacting with the form | `progress` (optional) | | `completed` | Yes | Human submitted response | `result`, `completed_at`, `responded_by` | | `expired` | Yes | Timeout reached | `expired_at`, `default_action` | | `cancelled` | Yes | Human clicked cancel | `cancelled_at`, `reason` | ## State Machine ``` +---------------------------------------------+ | v [created] -> pending -> opened -> in_progress -> completed [terminal] | | | | | +---------> cancelled [terminal] | | | +--> completed [terminal] | +--> expired [terminal] | +--> cancelled [terminal] | +----------> expired [terminal] +----------> cancelled [terminal] ``` Terminal states (`completed`, `expired`, `cancelled`) are immutable — no further transitions. ## For Services: Quick Start Return HTTP 202 when human input is needed: ```javascript // Express / Hono / any HTTP framework app.post('/api/search', async (req, res) => { const results = await searchJobs(req.body.query); // Create review case with opaque token const caseId = `review_${crypto.randomBytes(16).toString('hex')}`; const [REDACTED_SECRET]).toString('base64url'); // 43 chars const tokenHash = crypto.createHash('sha256').update(token).digest('hex'); store.set(caseId, { status: 'pending', tokenHash, results, created_at: new Date().toISOString(), expires_at: new Date(Date.now() + 86400000).toISOString(), }); res.status(202).json({ status: 'human_input_required', message: `${results.length} jobs found. Please select which ones to apply for.`, hitl: { spec_version: '0.6', case_id: caseId, review_url: `https://yourservice.com/review/${caseId}?[REDACTED_SECRET]`, poll_url: `https://api.yourservice.com/v1/reviews/${caseId}/status`, type: 'selection', prompt: 'Select which jobs to apply for', timeout: '24h', default_action: 'skip', created_at: store.get(caseId).created_at, expires_at: store.get(caseId).expires_at, }, }); }); ``` You also need: a review page (any web framework), a poll endpoint (`GET /reviews/:caseId/status`), and a response endpoint (`POST /reviews/:caseId/respond`). See [Service Integration Guide](skills/references/service-integration.md) for full details. ## For Agents: Quick Start Handle HTTP 202 responses — ~15 lines: ```python import time, httpx response = httpx.post("https://api.jobboard.com/search", json=query) if response.status_code == 202: hitl = response.json()["hitl"] # v0.7: Check for inline submit support if "submit_url" in hitl and "submit_token" in hitl: # Render native buttons in messaging platform (e.g. Telegram, Slack) send_inline_buttons(hitl["prompt"], hitl["inline_actions"], hitl["review_url"]) # When human taps button → POST to submit_url (see Agent Integration Guide) else: # Standard flow: forward URL to human send_to_user(f"{hitl['prompt']}\n{hitl['review_url']}") # Poll for result (standard flow or fallback) while True: time.sleep(30) poll = httpx.get(hitl["poll_url"], headers=auth).json() if poll["status"] == "completed": result = poll["result"] # {action: "select", data: {...}} break if poll["status"] in ("expired", "cancelled"): break ``` No SDK. No UI rendering. Just HTTP + URL forwarding + polling. See [Agent Integration Guide](skills/references/agent-integration.md) for inline submit, SSE, callbacks, multi-round, and edge cases. ## Three Transport Modes | Transport | Agent needs public endpoint? | Real-time? | Complexity | |-----------|:---------------------------:|:----------:|:----------:| | **Polling** (default) | No | No | Minimal | | **SSE** (optional) | No | Yes | Low | | **Callback** (optional) | Yes | Yes | Medium | Polling is the baseline — every HITL-compliant service MUST support it. SSE and callbacks are optional enhancements. ## Channel-Native Inline Actions (v0.7) For simple decisions, agents can render **native messaging buttons** instead of sending a URL. The human taps a button directly in the chat — no browser switch needed. **How it works:** The service includes `submit_url` + `submit_token` in the HITL object. The agent detects these fields and renders platform-native buttons. When the human taps a button, the agent POSTs the action to `submit_url`. The messaging platform is passive — it renders whatever the agent sends. No messenger auto-detects HITL support. **When to use inline buttons:** | Review type | Inline possible? | Reason | |-------------|:----------------:|--------| | **Confirmation** | Yes | 2 buttons: Confirm / Cancel | | **Escalation** | Yes | 3 buttons: Retry / Skip / Abort | | **Approval** (simple) | Yes | 2 buttons: Approve / Reject (without edit) | | **Approval** (with edit) | URL only | Edit requires rich UI | | **Selection** | URL only | Needs list/cards UI | | **Input** | URL only | Needs form fields | **Always include a URL fallback button** (e.g. "Details &#8594;") linking to `review_url` — the human can always switch to the full review page. **Platform requirements:** The agent must be a platform bot (Telegram Bot via BotFather, Slack App, Discord Bot, WhatsApp Business API, Teams Bot) to send native buttons. See [Agent Integration Guide](skills/references/agent-integration.md) for platform-specific rendering patterns. ## Non-Goals - **Does NOT render review UI** — the service hosts and renders the review page. The agent is a messenger. - **Does NOT define the review page framework** — any web technology works (React, plain HTML, etc.). - **Does NOT replace OAuth** — HITL is for decisions, not authentication. - **Does NOT submit on behalf of the human** — unless the human explicitly triggers an inline action button in a messaging platform (v0.7 `submit_url`). ## SKILL.md Extension for Services Services that use HITL can declare support in their own SKILL.md frontmatter: ```yaml metadata: hitl: supported: true types: [selection, confirmation] supports_inline_submit: true review_base_url: "https://yourservice.com/review" timeout_default: "24h" info: "May ask user to select preferred jobs or confirm applications." ``` See [spec Section 12](spec/v0.7/hitl-protocol.md) for the full field reference. ### Best Practice: Enforce HITL Choice **Problem:** Agents tend to take the efficient path and silently skip HITL — defaulting to no human review. Even though the protocol says agents MUST NOT ignore HTTP 202, nothing prevents an agent from never sending requests that trigger HITL in the first place. **Solution:** Force agents to make an **explicit choice** per request. Add `prefer_hitl` and `skip_hitl` flags to your API and reject requests that provide neither. Declare this in your SKILL.md: ```yaml metadata: hitl: supported: true hitl_required: true types: [selection, confirmation] info: "HITL is required. You MUST send prefer_hitl: true or skip_hitl: true." ``` Add an **Agent Guidelines** section to your SKILL.md body: ```markdown ## Agent Guidelines HITL is required for this service. Every request MUST include exactly one of: | Parameter | Type | Effect | |-----------|------|--------| | `prefer_hitl` | boolean | `true` → human reviews via HITL (HTTP 202) | | `skip_hitl` | boolean | `true` → direct execution, no human review (HTTP 201) | You MUST choose one. Omitting both returns 400 `HITL_CHOICE_REQUIRED`. Sending both returns 400 validation error. ### Example: With HITL POST /api/search {"query": "Senior Dev Berlin", "prefer_hitl": true} → 202 + hitl object (human reviews results) ### Example: Without HITL POST /api/search {"query": "Senior Dev Berlin", "skip_hitl": true} → 201 {"results": [...], "hitl_skipped": true} ### Example: Missing choice POST /api/search {"query": "Senior Dev Berlin"} → 400 {"error": "HITL_CHOICE_REQUIRED", "message": "You must send prefer_hitl or skip_hitl."} ``` See [Service Integration Guide — Enforcing HITL Choice](skills/references/service-integration.md#enforcing-hitl-choice) for validation gate implementation. ### Optional: Quality Improvement Signal Services can include `improvement_suggestions` in successful (non-202) responses to give agents structured feedback they can act on — without requiring a new HITL flow. Declare support in your SKILL.md: ```yaml metadata: hitl: supported: true supports_improvement_suggestions: true info: "Returns improvement_suggestions in 201 responses. Agents may offer up to 2 improvement cycles." ``` **Suggestion object schema:** | Field | Type | Description | |-------|------|-------------| | `field` | string | Which data field can be improved | | `issue` | string | What is missing or suboptimal | | `agent_action` | string | Exact question/action for the agent to take | | `impact` | string | Human-readable benefit (e.g. `"+25 quality points"`) | | `priority` | `"high"` / `"medium"` / `"low"` | Order of importance | **Agent behavior:** - Always share the primary result first - Offer up to **2 improvement cycles** — one question per suggestion, re-submit with enriched data - Stop when `improvement_suggestions` is empty or `maxAttempts` reached - Never loop indefinitely See [Agent Checklist — Quality Improvement Loop](agents/checklist.md#enhanced-quality-improvement-loop) and [Example 13](examples/13-quality-improvement-loop.json). ## RFC Alignment This protocol and skill documentation are aligned with these core RFCs: - [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110) — HTTP semantics (`202 Accepted`, conditional requests, retry behavior) - [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119) + [RFC 8174](https://www.rfc-editor.org/rfc/rfc8174) — normative terms (`MUST`, `SHOULD`, `MAY`) - [RFC 3339](https://www.rfc-editor.org/rfc/rfc3339) — timestamp formats used by HITL case lifecycle fields - [RFC 6750](https://www.rfc-editor.org/rfc/rfc6750) — bearer token usage for review and inline submit authorization For the complete implementation matrix, see [README RFC Alignment](README.md#rfc-alignment). ## Resources - [Full Specification (v0.7)](spec/v0.7/hitl-protocol.md) - [OpenAPI 3.1 Spec](schemas/openapi.yaml) — all endpoints documented - [JSON Schemas](schemas/) — HITL object, poll response, form field, submit request definitions - [Reference Implementations](implementations/reference-service/) — Express 5, Hono, Next.js, FastAPI - [Review Page Templates](templates/) — HTML templates for all 5 review types - [Examples](examples/) — 12 end-to-end flows (incl. inline confirmation, escalation, hybrid approval) - [Agent Implementation Checklist](agents/checklist.md) — detailed agent guide with pseudocode - [Interactive Playground](playground/) - [SDK Design Guide](docs/sdk-guide.md) — build a community SDK

path: README.md
content: # HITL Protocol — Agent Skill

The root-level [SKILL.md](../SKILL.md) teaches autonomous agents the HITL Protocol so they can build HITL-compliant services or handle HITL responses.

## Structure

```
SKILL.md                          ← Protocol overview, quick starts, feature matrix
skills/
  README.md                       ← You are here
  references/
    service-integration.md        ← Detailed guide for service/website builders
    agent-integration.md          ← Detailed guide for agent developers
```

Progressive disclosure (per [Agent Skills Standard](https://agentskills.io/specification)):
- **Discovery:** Frontmatter `name` + `description` (~100 tokens)
- **Activation:** Full SKILL.md body loaded when task matches (~1600 words)
- **Execution:** Reference files loaded on demand

## Publishing

### ClawhHub.ai (for OpenClaw and autonomous agents)

```bash
clawhub publish ./SKILL.md \
  --slug hitl-protocol \
  --name "HITL Protocol" \
  --version 0.5.0 \
  --tags "protocol,hitl,human-in-the-loop,agent-ready,http-202"
```

Requires a [ClawhHub.ai](https://clawhub.ai) account (GitHub login).

### Claude Marketplace (for Claude and classical agents)

Submit the SKILL.md via the [Anthropic Skills submission process](https://platform.claude.com/docs/en/agents-and-tools/agent-skills).

### GitHub Discovery

The SKILL.md in the repository root is automatically discoverable by agents that scan GitHub repositories for agent skills (Codex, Copilot, etc.).

## For Services Using HITL

If your service integrates HITL Protocol, declare support in **your own** SKILL.md frontmatter using the `metadata.hitl` extension:

```yaml
---
name: your-service-name
description: "Your service description"
metadata:
  hitl:
    supported: true
    types: [selection, confirmation]
    review_base_url: "https://yourservice.com/review"
    timeout_default: "24h"
    info: "May ask user to select items or confirm actions."
---
```

See [spec Section 12](../spec/v0.5/hitl-protocol.md) for the full `metadata.hitl` field reference.


path: references/agent-integration.md
content: # HITL Protocol — Agent Integration Guide

This guide is for **agent developers** who want to handle HITL responses from services.

## Core Concept

When a service needs human input, it returns HTTP 202 instead of 200. The response body contains a `hitl` object with a `review_url` (for the human) and a `poll_url` (for the agent). The agent forwards the URL, polls for the result, and continues.

## Implementation Checklist

### Minimum (Polling)

- [ ] **Detect HTTP 202** — check `response.status_code == 202`
- [ ] **Check for `hitl` in body** — not all 202s are HITL (standard async exists)
- [ ] **Validate required fields** — `review_url`, `poll_url`, `type`, `prompt`, `case_id`, `created_at`, `expires_at`
- [ ] **Forward review URL** — send `hitl.review_url` to human with `hitl.prompt` as context
- [ ] **Poll for result** — `GET hitl.poll_url` at 30-second to 5-minute intervals
- [ ] **Handle `completed`** — extract `result.action` and `result.data`, continue workflow
- [ ] **Handle `expired`** — execute `hitl.default_action` or inform user
- [ ] **Handle `cancelled`** — inform user, abort or skip
- [ ] **Respect rate limits** — max 60 requests/min per case, check `Retry-After` header

### Enhanced (Optional)

- [ ] **SSE transport** — connect to `hitl.events_url` if present
- [ ] **Callback transport** — include `hitl_callback_url` in original request
- [ ] **Multi-round** — follow `next_case_id` for edit cycles
- [ ] **Input form metadata** — relay form step count/titles to human
- [ ] **Sensitive fields** — do NOT log values for fields with `sensitive: true`
- [ ] **Reminders** — re-send URL at `hitl.reminder_at` timestamps
- [ ] **Progress tracking** — relay `progress` from `in_progress` poll responses

## Complete Polling Implementation

```python
import time
import httpx

def handle_response(response, send_to_user, auth_headers):
    """Handle any HTTP response, detecting HITL when present."""

    if response.status_code != 202:
        return response.json()  # Normal response

    body = response.json()
    hitl = body.get("hitl")
    if not hitl:
        return body  # 202 without HITL (standard async)

    # Validate required fields
    for field in ("review_url", "poll_url", "type", "prompt", "case_id"):
        if field not in hitl:
            raise ValueError(f"HITL object missing required field: {field}")

    # Forward URL to human
    message = body.get("message", hitl["prompt"])
    send_to_user(f"{message}\n\n{hitl['review_url']}")

    # Poll for result
    while True:
        time.sleep(30)
        poll_response = httpx.get(hitl["poll_url"], headers=auth_headers)

        # Handle rate limiting
        if poll_response.status_code == 429:
            retry_after = int(poll_response.headers.get("Retry-After", 60))
            time.sleep(retry_after)
            continue

        poll = poll_response.json()
        status = poll["status"]

        if status == "completed":
            return poll["result"]  # {action: "select", data: {...}}

        if status == "expired":
            default = hitl.get("default_action", "skip")
            send_to_user(f"Review expired. Using default action: {default}")
            return {"action": default, "data": {}, "expired": True}

        if status == "cancelled":
            reason = poll.get("reason", "User cancelled")
            send_to_user(f"Review cancelled: {reason}")
            return {"action": "cancelled", "data": {}, "cancelled": True}

        # pending, opened, in_progress → keep polling
        if status == "opened":
            pass  # Optional: send_to_user("Review page opened")
        if status == "in_progress" and "progress" in poll:
            p = poll["progress"]
            pass  # Optional: send_to_user(f"Step {p['current_step']}/{p['total_steps']}")
```

## URL Delivery Strategies

Choose based on your agent's environment:

| Mode | When | How |
|------|------|-----|
| **Messaging** (default) | Agent is a bot (Telegram, Slack, Discord, WhatsApp) | Send URL as clickable link in chat message |
| **Messaging + Inline** (v0.7) | Bot + Service provides `submit_url` | Native buttons in chat + URL fallback |
| **Desktop CLI** | Agent runs on user's own machine | `webbrowser.open(url)` |
| **Remote CLI** | Agent on remote server (SSH) | Print URL (optionally QR code) |

In most real deployments, the agent is a bot on a server — not on the human's device. The agent sends messages to the human via the messaging platform's API. The human taps the URL link, and the browser opens on **their** device.

```python
from urllib.parse import urlparse

def handle_hitl(hitl: dict, send_to_user) -> None:
    """Forward a HITL review to the human."""
    review_url = hitl["review_url"]

    # Validate URL
    parsed = urlparse(review_url)
    if parsed.scheme != "https" or not parsed.netloc:
        raise ValueError("Invalid review URL: must be HTTPS with a valid host")

    # v0.7: Inline buttons for simple decisions (messaging platforms)
    if "submit_url" in hitl and "submit_token" in hitl:
        send_inline_buttons(
            prompt=hitl["prompt"],
            actions=hitl.get("inline_actions", []),
            review_url=review_url,       # Always include URL fallback
        )
    else:
        # Standard: send URL as clickable link
        send_to_user(f"{hitl['prompt']}\n{review_url}")
```

> **Desktop CLI agents** (running on the user's machine, e.g. Claude Code): Use `webbrowser.open(review_url)` to open the URL directly in the user's browser. This only applies when agent and human share the same device.

> **Remote CLI agents** (SSH): Print the URL for manual opening. Optionally render a QR code with `qrencode -t ANSI <url>` if installed.

## SSE Event Stream

If `hitl.events_url` is present, use SSE for real-time updates instead of polling:

```python
import httpx_sse, json

def handle_hitl_sse(hitl, send_to_user, auth_headers):
    events_url = hitl.get("events_url")
    if not events_url:
        return handle_hitl_polling(hitl, send_to_user, auth_headers)

    try:
        with httpx_sse.connect(events_url, headers=auth_headers) as sse:
            for event in sse:
                if event.event == "review.completed":
                    data = json.loads(event.data)
                    return data["result"]

                elif event.event == "review.expired":
                    data = json.loads(event.data)
                    return {"action": data["default_action"], "expired": True}

                elif event.event == "review.cancelled":
                    data = json.loads(event.data)
                    return {"action": "cancelled", "reason": data.get("reason")}

                elif event.event == "review.opened":
                    send_to_user("Review page opened")

                elif event.event == "review.reminder":
                    send_to_user(f"Reminder: {hitl['review_url']}")

    except ConnectionError:
        return handle_hitl_polling(hitl, send_to_user, auth_headers)
```

Support reconnection via `Last-Event-ID` header. Always fall back to polling on failure.

### SSE Event Types

| Event | Payload | When |
|-------|---------|------|
| `review.opened` | `{case_id, opened_at}` | Human opens URL |
| `review.in_progress` | `{case_id, progress}` | Human interacts |
| `review.completed` | `{case_id, completed_at, result}` | Human submits |
| `review.expired` | `{case_id, expired_at, default_action}` | Timeout |
| `review.cancelled` | `{case_id, cancelled_at, reason}` | Human cancels |
| `review.reminder` | `{case_id, review_url}` | Reminder triggered |

## Callback/Webhook

For agents with a publicly reachable endpoint:

1. Include `hitl_callback_url` in your original API request
2. Expose `POST /webhooks/hitl` on your server
3. Verify signature: `X-HITL-Signature: sha256=<hmac>`

```python
import hmac, hashlib

def verify_hitl_signature(body: bytes, signature_header: str, secret: str) -> bool:
    expected = "sha256=" + hmac.new(
        secret.encode(), body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature_header)
```

Still maintain polling as fallback — the poll endpoint is the source of truth.

## Multi-Round Reviews

Approval-type reviews may involve edit cycles:

```
Round 1: case_id="review_draft1"
  → Human: action="edit", data={feedback: "Fix the intro"}
  → Poll: next_case_id="review_draft2"

Round 2: case_id="review_draft2", previous_case_id="review_draft1"
  → Agent revises artifact based on feedback
  → Human: action="approve"
  → Workflow continues
```

After `completed` with `action="edit"`:
1. Check `poll.next_case_id` for the follow-up case
2. Revise your artifact based on `result.data.feedback`
3. Poll the new case until it completes

## Input Form Handling

When `type` is `"input"`, the `context.form` tells you about the form structure:

```python
hitl = response.json()["hitl"]

if hitl["type"] == "input" and "form" in hitl.get("context", {}):
    form = hitl["context"]["form"]

    if "steps" in form:
        # Multi-step wizard
        step_titles = [s["title"] for s in form["steps"]]
        total_fields = sum(len(s["fields"]) for s in form["steps"])
        send_to_user(
            f"{len(form['steps'])}-step form ({', '.join(step_titles)}). "
            f"{total_fields} fields total.\n{hitl['review_url']}"
        )
    elif "fields" in form:
        # Single-step form
        required = [f for f in form["fields"] if f.get("required")]
        send_to_user(
            f"Please fill {len(form['fields'])} fields "
            f"({len(required)} required).\n{hitl['review_url']}"
        )
```

### Sensitive Fields

Fields with `sensitive: true` (like salary, passwords) MUST NOT be logged or displayed by the agent. Only the human sees these values in the browser.

## Reminders

If `hitl.reminder_at` is present:

```python
import datetime

reminder_at = hitl.get("reminder_at")
if reminder_at:
    # Can be a single timestamp or array
    timestamps = [reminder_at] if isinstance(reminder_at, str) else reminder_at

    for ts in timestamps:
        reminder_time = datetime.datetime.fromisoformat(ts)
        # Schedule: if case is still pending/opened at reminder_time,
        # re-send the review URL to the human
```

Do NOT send reminders for terminal states (`completed`, `expired`, `cancelled`).

## Channel-Native Inline Actions (v0.7)

When a service includes `submit_url` and `submit_token` in the HITL object, the agent MAY render native messaging buttons for simple decisions instead of (or in addition to) a URL link.

### Detection

```python
def has_inline_submit(hitl: dict) -> bool:
    return "submit_url" in hitl and "submit_token" in hitl
```

### Rendering Decision

```python
def render_hitl_message(hitl: dict, platform: str):
    review_type = hitl["type"]
    inline_actions = hitl.get("inline_actions")

    if not has_inline_submit(hitl):
        # No inline submit → URL-only (v0.5 behavior)
        return render_url_button(hitl["review_url"], "Review")

    if review_type == "confirmation":
        # 2 buttons: Confirm + Cancel — works on ALL platforms (incl. WhatsApp max 3)
        return render_inline_plus_url(
            actions=inline_actions or ["confirm", "cancel"],
            url=hitl["review_url"],
            url_label="View Details"
        )

    if review_type == "escalation":
        # 3 buttons: Retry + Skip + Abort — fits WhatsApp max 3
        # But if 'retry' is NOT in inline_actions, show only skip/abort inline
        return render_inline_plus_url(
            actions=inline_actions or ["retry", "skip", "abort"],
            url=hitl["review_url"],
            url_label="View Error Details"
        )

    if review_type == "approval":
        if inline_actions and "edit" not in inline_actions:
            # Simple approve/reject inline
            return render_inline_plus_url(
                actions=inline_actions,
                url=hitl["review_url"],
                url_label="Review & Edit"
            )
        else:
            # Edit needs browser → URL only
            return render_url_button(hitl["review_url"], "Review & Approve")

    if review_type in ("selection", "input"):
        # Always needs full review page
        if platform == "telegram":
            return render_webapp_button(hitl["review_url"], "Open")
        else:
            return render_url_button(hitl["review_url"], "Open")
```

### Inline Submit Handler

```python
import httpx

async def handle_inline_action(case_mapping: dict, action: str, user_info: dict):
    """Called when a human taps a native messaging button."""

    response = await httpx.AsyncClient().post(
        case_mapping["submit_url"],
        headers={
            "Authorization": f"Bearer {case_mapping['submit_token']}",
            "Content-Type": "application/json"
        },
        json={
            "action": action,
            "data": {},
            "submitted_via": user_info["submitted_via"],
            "submitted_by": {
                "platform": user_info["platform"],
                "platform_user_id": user_info["platform_user_id"],
                "display_name": user_info.get("display_name")
            }
        }
    )

    if response.status_code == 200:
        return {"success": True, "result": response.json()}
    elif response.status_code == 403:
        # Action not permitted inline → direct to review_url
        error = response.json()
        return {"success": False, "redirect_url": error.get("review_url")}
    elif response.status_code == 409:
        return {"success": False, "reason": "already_responded"}
    elif response.status_code == 410:
        return {"success": False, "reason": "expired"}
    else:
        return {"success": False, "reason": f"http_{response.status_code}"}
```

### Platform-Specific Callback Data Encoding

Each messaging platform has different limits for action callback data. The agent must encode enough information to identify the case and action when a button is tapped.

| Platform | Field | Limit | Strategy |
|----------|-------|-------|----------|
| **Telegram** | `callback_data` | 64 bytes | `hitl:{sha256(case_id)[:6]}:{action}` + agent-side mapping table |
| **Slack** | `value` | 2000 chars | Full JSON: `{"case_id":"...","action":"..."}` — no mapping needed |
| **Discord** | `custom_id` | 100 chars | `hitl:{case_id}:{action}` — usually fits directly |
| **WhatsApp** | `reply.id` | 256 chars | `hitl_{case_id}_{action}` — usually fits directly |
| **MS Teams** | `Action.Submit data` | Unbounded JSON | Full metadata in card action data |

**Telegram mapping table example:**

```python
import hashlib

# Agent-side in-memory mapping (or Redis/SQLite for persistence)
case_store: dict[str, dict] = {}

def register_case(hitl: dict) -> str:
    """Generate a 6-char short ID and store the case mapping."""
    short_id = hashlib.sha256(hitl["case_id"].encode()).hexdigest()[:6]
    case_store[short_id] = {
        "case_id": hitl["case_id"],
        "submit_url": hitl["submit_url"],
        "submit_token": hitl["submit_token"],
        "review_url": hitl["review_url"]
    }
    return short_id

def callback_data(short_id: str, action: str) -> str:
    """Generate Telegram callback_data (max 64 bytes)."""
    return f"hitl:{short_id}:{action}"  # e.g., "hitl:a1b2c3:confirm" = 20 bytes

def resolve_callback(data: str) -> tuple[dict, str] | None:
    """Parse callback_data and return (case_mapping, action)."""
    if not data.startswith("hitl:"):
        return None
    parts = data.split(":")
    short_id, action = parts[1], parts[2]
    case = case_store.get(short_id)
    return (case, action) if case else None
```

### Security: Never Leak submit_token

The `submit_token` MUST NOT appear in:
- Telegram `callback_data` (transits Telegram servers)
- Discord `custom_id` (transits Discord servers)
- Slack `action_id` or `value` (transits Slack servers)
- WhatsApp `reply.id` (transits WhatsApp servers)
- Any client-side or third-party visible field

Store `submit_token` only in the agent's own memory/database. Look it up using the case mapping when a button callback arrives.

### Enhanced Checklist (v0.7)

- [ ] **Detect `submit_url`** — check if `submit_url` and `submit_token` are present in the HITL object
- [ ] **Render native buttons** — for confirmation, escalation, simple approval when inline submit is available
- [ ] **Always include URL fallback** — render a URL button to `review_url` alongside inline buttons
- [ ] **Handle inline submit response** — `200` OK, `403` action not inline, `409` duplicate, `410` expired
- [ ] **Update message after action** — edit original message, remove buttons, show result
- [ ] **Never leak submit_token** — store only agent-side, never in callback data
- [ ] **Respect inline_actions** — only render buttons for actions listed in `inline_actions`
- [ ] **Fallback to URL** — if platform doesn't support buttons or submit_url is absent, use URL-only delivery

## What NOT to Do

- **Do NOT render the review UI** — the service hosts the review page. The agent is a messenger.
- **Do NOT submit responses on behalf of the human** — unless the human explicitly triggered a native messaging button and `submit_url` is available.
- **Do NOT ignore HTTP 202 + HITL** — proceeding without human input violates the protocol.
- **Do NOT poll too frequently** — respect rate limits (max 60/min). Check `Retry-After` header.
- **Do NOT store review URLs long-term** — they contain time-limited tokens that expire.
- **Do NOT log sensitive field values** — fields marked `sensitive: true` are for human eyes only.
- **Do NOT put `submit_token` in callback data** — it must never transit through third-party messaging servers.

## Decision Tree

```
Agent receives HTTP response
│
├── Status 200 → Use result directly
│
├── Status 202 + "hitl" in body
│   ├── Forward hitl.review_url to human with hitl.prompt
│   ├── Poll hitl.poll_url (or connect SSE / register callback)
│   │
│   ├── completed → use result.action + result.data
│   │   └── Check next_case_id for multi-round
│   ├── expired → execute default_action, inform human
│   └── cancelled → inform human, skip/abort
│
├── Status 202 without "hitl" → standard async (not HITL)
│
├── Status 429 → wait Retry-After seconds, retry
├── Status 4xx → handle client error
└── Status 5xx → retry with exponential backoff
```

## Detailed Agent Checklist

For the complete implementation guide with pseudocode for all transport modes, multi-round reviews, input forms, reminders, and delivery modes, see [agents/checklist.md](../../agents/checklist.md).


path: references/service-integration.md
content: # HITL Protocol — Service Integration Guide

This guide is for **service and website builders** who want to make their service accessible to autonomous agents via HITL Protocol.

## When to Return HTTP 202

Return HTTP 202 (not 200) when:
- The operation requires human judgment (approve a draft, select from options)
- Sensitive data needs human review before proceeding
- An irreversible action needs human confirmation
- The service needs structured input the agent cannot provide

Return HTTP 200 when the operation completes without human input.

## Enforcing HITL Choice

Agents default to the efficient path — skipping human review entirely. To prevent this, require agents to explicitly declare their intent via `prefer_hitl` or `skip_hitl` flags in every request.

### Decision Matrix

| `prefer_hitl` | `skip_hitl` | Result |
|:-:|:-:|--------|
| `true` | — | HTTP 202 + HITL object (human reviews) |
| — | `true` | HTTP 201 + `hitl_skipped: true` (direct execution) |
| — | — | HTTP 400 `HITL_CHOICE_REQUIRED` |
| `true` | `true` | HTTP 400 validation error (mutually exclusive) |

### Validation Gate (JavaScript)

```javascript
app.post('/api/action', async (req, res) => {
  const { prefer_hitl, skip_hitl, ...params } = req.body;

  // Reject ambiguous requests
  if (prefer_hitl && skip_hitl) {
    return res.status(400).json({
      error: 'VALIDATION_ERROR',
      message: 'prefer_hitl and skip_hitl are mutually exclusive.',
    });
  }
  if (!prefer_hitl && !skip_hitl) {
    return res.status(400).json({
      error: 'HITL_CHOICE_REQUIRED',
      message: 'You must send prefer_hitl: true or skip_hitl: true.',
    });
  }

  const result = await executeAction(params);

  if (skip_hitl) {
    // Direct execution — no human review
    return res.status(201).json({ ...result, hitl_skipped: true });
  }

  // HITL flow — create review case
  const hitl = await createReviewCase(result);
  return res.status(202).json({
    status: 'human_input_required',
    message: hitl.prompt,
    hitl,
  });
});
```

### Validation Gate (Python)

```python
@app.post("/api/action")
async def action(request: Request):
    body = await request.json()
    prefer_hitl = body.get("prefer_hitl", False)
    skip_hitl = body.get("skip_hitl", False)

    if prefer_hitl and skip_hitl:
        return JSONResponse(status_code=400, content={
            "error": "VALIDATION_ERROR",
            "message": "prefer_hitl and skip_hitl are mutually exclusive.",
        })
    if not prefer_hitl and not skip_hitl:
        return JSONResponse(status_code=400, content={
            "error": "HITL_CHOICE_REQUIRED",
            "message": "You must send prefer_hitl: true or skip_hitl: true.",
        })

    result = await execute_action(body)

    if skip_hitl:
        return JSONResponse(status_code=201, content={**result, "hitl_skipped": True})

    hitl = await create_review_case(result)
    return JSONResponse(status_code=202, content={
        "status": "human_input_required",
        "message": hitl["prompt"],
        "hitl": hitl,
    })
```

### HITL Continuation Chains

When a HITL flow triggers a follow-up request (e.g. `next_case_id`), the agent is already in a HITL context. In this case, neither flag is needed — derive `prefer_hitl` automatically:

```javascript
// If request contains a previous case_id, it's a HITL continuation
const isHitlContinuation = !!req.body.previous_case_id;
const effectivePreferHitl = prefer_hitl || isHitlContinuation;
```

### Documenting in Your SKILL.md

Add this to your service's SKILL.md so agents know the requirement upfront:

```yaml
metadata:
  hitl:
    supported: true
    hitl_required: true
    types: [selection, confirmation]
    info: "HITL is required. You MUST send prefer_hitl: true or skip_hitl: true."
```

## Implementation Checklist

- [ ] Return HTTP 202 with `hitl` object when human input is needed
- [ ] Generate opaque bearer token (43 chars, base64url, 256-bit entropy)
- [ ] Store only SHA-256 hash of token (never store raw token)
- [ ] Host a review page at `review_url`
- [ ] Implement poll endpoint at `poll_url` (GET, returns status + result)
- [ ] Implement response endpoint (POST, accepts human's decision)
- [ ] Enforce one-response per case (409 Conflict on duplicate)
- [ ] Implement state machine with valid transitions only
- [ ] Set appropriate timeout and default_action
- [ ] Return `Retry-After` header on rate limit (429)

## HITL Object — Complete Field Reference

### Required Fields

| Field | Type | Constraints | Description |
|-------|------|-------------|-------------|
| `spec_version` | string | Must be `"0.5"` | Protocol version |
| `case_id` | string | Pattern: `^[a-zA-Z0-9_-]+$` | Unique identifier. Recommended: `review_{random}` |
| `review_url` | URL | HTTPS only, includes opaque token | URL to the review page |
| `poll_url` | URL | HTTPS recommended | Status polling endpoint |
| `type` | enum | `approval` / `selection` / `input` / `confirmation` / `escalation` / `x-*` | Review type |
| `prompt` | string | Max 500 chars | What the human needs to decide |
| `created_at` | datetime | ISO 8601 | When the case was created |
| `expires_at` | datetime | ISO 8601 | When the case expires |

### Optional Fields

| Field | Type | Description |
|-------|------|-------------|
| `timeout` | duration | How long review stays open. ISO 8601 (`PT24H`, `P7D`) or shorthand (`24h`, `7d`) |
| `default_action` | enum | `skip` / `approve` / `reject` / `abort` — action taken on expiry |
| `callback_url` | URL / null | Echo of agent's callback URL, or null |
| `events_url` | URL | SSE endpoint for real-time events |
| `context` | object | Arbitrary data for the review page. For `input` type, MAY include `form` |
| `reminder_at` | datetime / datetime[] | When to send reminder(s) |
| `previous_case_id` | string | Links to prior case in multi-round chain |
| `surface` | object | UI format declaration: `{format: "json-render", version: "1.0"}` |

## Security: Token Generation

```javascript
import crypto from 'crypto';

// Generate opaque bearer token (43 chars, 256-bit entropy)
const [REDACTED_SECRET]).toString('base64url');

// Store ONLY the hash — never persist the raw token
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');

// Build review URL
const reviewUrl = `https://yourservice.com/review/${caseId}?[REDACTED_SECRET]`;

// Verify token on review page access
function verifyToken(incomingToken, storedHash) {
  const incomingHash = crypto.createHash('sha256').update(incomingToken).digest('hex');
  return crypto.timingSafeEqual(
    Buffer.from(incomingHash, 'hex'),
    Buffer.from(storedHash, 'hex')
  );
}
```

**Why opaque tokens instead of JWT?**
- 43 chars vs 241 chars — shorter URLs, no LLM tokenizer corruption
- Bearer model — URL sharing is delegation by design
- SHA-256 hash storage — compromised DB doesn't leak tokens

## State Machine

All valid transitions:

| From | To | Trigger |
|------|----|---------|
| *(created)* | `pending` | Service creates case |
| `pending` | `opened` | Human opens review URL |
| `pending` | `expired` | Timeout reached |
| `pending` | `cancelled` | Human clicks cancel |
| `opened` | `in_progress` | Human starts interacting with form |
| `opened` | `completed` | Human submits response |
| `opened` | `expired` | Timeout reached |
| `opened` | `cancelled` | Human clicks cancel |
| `in_progress` | `completed` | Human submits response |
| `in_progress` | `cancelled` | Human clicks cancel |

**Terminal states** (`completed`, `expired`, `cancelled`) are **immutable** — no transitions out.

**Optional intermediate states:** `opened` and `in_progress` are optional. Services that don't track page views may transition directly from `pending` to terminal states.

## Poll Endpoint Implementation

```
GET /v1/reviews/{caseId}/status
Authorization: Bearer <agent-token>  (or other auth)
```

Return the current status with appropriate fields per state:

```javascript
app.get('/v1/reviews/:caseId/status', (req, res) => {
  const rc = store.get(req.params.caseId);
  if (!rc) return res.status(404).json({ error: 'Case not found' });

  const response = { status: rc.status, case_id: rc.case_id };

  // Always include timestamps
  if (rc.created_at) response.created_at = rc.created_at;
  if (rc.expires_at) response.expires_at = rc.expires_at;
  if (rc.opened_at) response.opened_at = rc.opened_at;

  // Terminal state fields
  if (rc.status === 'completed') {
    response.completed_at = rc.completed_at;
    response.result = rc.result; // { action, data }
    if (rc.responded_by) response.responded_by = rc.responded_by;
    if (rc.next_case_id) response.next_case_id = rc.next_case_id;
  }
  if (rc.status === 'expired') {
    response.expired_at = rc.expired_at;
    response.default_action = rc.default_action;
  }
  if (rc.status === 'cancelled') {
    response.cancelled_at = rc.cancelled_at;
    if (rc.reason) response.reason = rc.reason;
  }

  // Progress tracking (optional, for multi-step Input forms)
  if (rc.status === 'in_progress' && rc.progress) {
    response.progress = rc.progress;
  }

  res.json(response);
});
```

### Rate Limiting

- Recommend 60 requests/min per case
- Return HTTP 429 with `Retry-After` header when exceeded
- Support `ETag` / `If-None-Match` for efficient polling (304 Not Modified)

## Review Page

The review page is hosted by your service. Use any web framework. Requirements:

1. **Verify token** — check opaque token against stored SHA-256 hash
2. **Display context** — render the review UI based on `type` and `context`
3. **Collect response** — submit human's decision to response endpoint
4. **One response only** — return 409 Conflict on duplicate submission

The agent never sees or renders this page. All sensitive data stays in the browser.

HTML templates for all 5 review types are available in [templates/](../../templates/).

## Response Endpoint

```
POST /v1/reviews/{caseId}/respond
Content-Type: application/json
```

```json
{
  "action": "select",
  "data": {
    "selected_jobs": ["job-123", "job-456"],
    "note": "Only remote positions"
  }
}
```

Valid `action` values per type:

| Type | Valid actions |
|------|-------------|
| Approval | `approve`, `edit`, `reject` |
| Selection | `select` |
| Input | `submit` |
| Confirmation | `confirm`, `cancel` |
| Escalation | `retry`, `skip`, `abort` |

## Form Field Definitions (Input Type)

For `input`-type reviews, include a `form` object in `context`:

### Single-Step Form

```json
{
  "context": {
    "form": {
      "fields": [
        {
          "key": "salary",
          "label": "Salary Expectation (EUR)",
          "type": "number",
          "required": true,
          "sensitive": true,
          "validation": { "min": 0, "max": 1000000 }
        },
        {
          "key": "start_date",
          "label": "Earliest Start Date",
          "type": "date",
          "required": true
        }
      ]
    }
  }
}
```

### Multi-Step Wizard

```json
{
  "context": {
    "form": {
      "session_id": "form_sess_x7k9m2",
      "steps": [
        {
          "title": "Personal Information",
          "description": "Basic contact details",
          "fields": [
            { "key": "full_name", "label": "Full Name", "type": "text", "required": true },
            { "key": "email", "label": "Email", "type": "email", "required": true }
          ]
        },
        {
          "title": "Preferences",
          "fields": [
            {
              "key": "employment_type",
              "label": "Employment Type",
              "type": "select",
              "required": true,
              "options": [
                { "value": "fulltime", "label": "Full-time" },
                { "value": "parttime", "label": "Part-time" }
              ]
            },
            {
              "key": "salary_range",
              "label": "Expected Salary (EUR)",
              "type": "range",
              "sensitive": true,
              "validation": { "min": 40000, "max": 200000 },
              "conditional": {
                "field": "employment_type",
                "operator": "eq",
                "value": "fulltime"
              }
            }
          ]
        }
      ]
    }
  }
}
```

Use `fields` (single-step) **or** `steps` (multi-step), never both.

### Field Types

| Type | HTML equiv | Value type | Notes |
|------|-----------|------------|-------|
| `text` | `<input type="text">` | string | Single-line |
| `textarea` | `<textarea>` | string | Multi-line |
| `number` | `<input type="number">` | number | Integer or decimal |
| `date` | `<input type="date">` | string (ISO 8601) | Date picker |
| `email` | `<input type="email">` | string | Email validation |
| `url` | `<input type="url">` | string | URL validation |
| `boolean` | `<input type="checkbox">` | boolean | Toggle |
| `select` | `<select>` | string | Single choice, requires `options` |
| `multiselect` | `<select multiple>` | string[] | Multiple choices, requires `options` |
| `range` | `<input type="range">` | number | Slider, requires `validation.min`/`max` |
| `x-*` | Custom | Any | Service-defined custom types |

### Field Properties

| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `key` | string | Yes | Unique field ID. Pattern: `^[a-zA-Z][a-zA-Z0-9_]*$` |
| `label` | string | Yes | Human-readable label (max 200 chars) |
| `type` | string | Yes | Field type from table above |
| `required` | boolean | No | Must be filled before submission (default: false) |
| `placeholder` | string | No | Placeholder text |
| `hint` | string | No | Help text displayed below field |
| `default` | any | No | Pre-filled value. MUST NOT contain sensitive data |
| `default_ref` | URL | No | URL to securely fetch pre-fill (requires token) |
| `sensitive` | boolean | No | Mask input, suppress logging (default: false) |
| `options` | array | For select/multiselect | `[{value, label}, ...]` |
| `validation` | object | No | `{minLength, maxLength, pattern, min, max}` |
| `conditional` | object | No | `{field, operator, value}`. Operators: `eq`, `neq`, `in`, `gt`, `lt` |

### Progress Tracking (Optional)

When a human is working on a multi-step form, include `progress` in `in_progress` poll responses:

```json
{
  "status": "in_progress",
  "progress": {
    "current_step": 2,
    "total_steps": 3,
    "completed_fields": 4,
    "total_fields": 8
  }
}
```

## SSE Event Stream (Optional)

If you implement real-time events, expose an `events_url`:

```
GET /v1/reviews/{caseId}/events
Accept: text/event-stream
```

Event types:

| Event | When | Data |
|-------|------|------|
| `review.opened` | Human opens URL | `{case_id, opened_at}` |
| `review.in_progress` | Human starts interacting | `{case_id, progress}` |
| `review.completed` | Human submits | `{case_id, completed_at, result}` |
| `review.expired` | Timeout reached | `{case_id, expired_at, default_action}` |
| `review.cancelled` | Human cancels | `{case_id, cancelled_at, reason}` |
| `review.reminder` | Reminder triggered | `{case_id, review_url}` |

Support `Last-Event-ID` for reconnection. Include `id` in each event.

## Callback/Webhook (Optional)

When the agent includes `hitl_callback_url` in the original request:

1. Echo it in `hitl.callback_url`
2. POST to the callback URL when the case reaches a terminal state
3. Sign with `X-HITL-Signature: sha256=<hmac>` (HMAC-SHA256 of request body)
4. Retry with exponential backoff (up to 3 attempts)

```json
POST {callback_url}
X-HITL-Signature: sha256=abc123...
Content-Type: application/json

{
  "event": "review.completed",
  "case_id": "review_abc123",
  "completed_at": "2026-02-22T10:15:00Z",
  "result": {
    "action": "select",
    "data": { "selected_jobs": ["job-123"] }
  }
}
```

## Service Discovery (Optional)

Expose a `.well-known/hitl.json` endpoint:

```json
{
  "hitl_protocol": "0.5",
  "service": {
    "name": "JobBoard Pro",
    "description": "Job search and application service"
  },
  "review_types": ["selection", "confirmation"],
  "review_base_url": "https://jobboard.example.com/review",
  "api_base_url": "https://api.jobboard.example.com/v1",
  "timeout_default": "24h",
  "features": {
    "callback": true,
    "signed_responses": false,
    "edit_grace_period": "5m"
  }
}
```

## Multi-Round Reviews (Approval Type)

For iterative edit cycles:

1. Service creates case with `type: "approval"`
2. Human responds with `action: "edit"` + feedback in `data`
3. Service creates new case with `previous_case_id` linking to the original
4. Poll response of original case includes `next_case_id`
5. Repeat until human responds with `action: "approve"` or `"reject"`

## SKILL.md Extension

Declare HITL support in your service's SKILL.md frontmatter (see [spec Section 12](../../spec/v0.5/hitl-protocol.md)):

```yaml
metadata:
  hitl:
    supported: true
    types: [selection, confirmation]
    review_base_url: "https://yourservice.com/review"
    timeout_default: "24h"
    info: "May ask user to select jobs or confirm applications."
```

| Field | Required | Description |
|-------|----------|-------------|
| `metadata.hitl.supported` | REQUIRED | `true` if service may return HITL responses |
| `metadata.hitl.types` | RECOMMENDED | Which review types the service may trigger |
| `metadata.hitl.review_base_url` | OPTIONAL | Base URL for review pages |
| `metadata.hitl.timeout_default` | OPTIONAL | Typical timeout duration |
| `metadata.hitl.info` | RECOMMENDED | When/why HITL is triggered (max 200 chars) |

## Reference Implementations

Working implementations in 4 frameworks:

| Framework | Path | Language |
|-----------|------|----------|
| Express 5 | [implementations/reference-service/express/](../../implementations/reference-service/express/) | Node.js |
| Hono | [implementations/reference-service/hono/](../../implementations/reference-service/hono/) | Edge/Deno/Bun |
| Next.js | [implementations/reference-service/nextjs/](../../implementations/reference-service/nextjs/) | TypeScript |
| FastAPI | [implementations/reference-service/python/](../../implementations/reference-service/python/) | Python |

## Common Implementation Mistakes

Real-world audits of services implementing HITL v0.7 have revealed recurring patterns. Avoid these:

### Mistake 1: Accepting Both Tokens Interchangeably (Security Critical)

**Wrong:**
```javascript
function verifyToken(token, hitlCase) {
  if (matchesHash(token, hitlCase.review_token_hash)) return true
  if (matchesHash(token, hitlCase.submit_token_hash)) return true  // ← WRONG
  return false
}
```

**Why it's dangerous:** If the `review_url` leaks (shared in chat, bookmarked, forwarded), an attacker can use the review token to make inline submissions — defeating the entire dual-token security model.

**Right:** Add a `purpose` parameter:
```javascript
function verifyToken(token, hitlCase, purpose) {
  if (purpose === 'review') return matchesHash(token, hitlCase.review_token_hash)
  if (purpose === 'submit') return matchesHash(token, hitlCase.submit_token_hash)
  return false
}
```

See [Spec Section 7.5](../../spec/v0.7/hitl-protocol.md) — `submit_token` MUST be different from the review URL token.

### Mistake 2: Wrong Body Format for Inline Submit

**Wrong:** Expecting `{ result: { action, data } }` for Bearer-authenticated inline submissions.

**Right:** Two distinct formats depending on the auth path:
- **Inline submit (Bearer header):** `{ action, data, submitted_via, submitted_by }` — flat
- **Review page (body/URL token):** `{ action, data }` or your existing format

See [Spec Section 7.5.1](../../spec/v0.7/hitl-protocol.md) for the inline submit request body.

### Mistake 3: HTTP 422 Instead of 403 for Invalid Inline Actions

**Wrong:** Returning `422 Unprocessable Entity` when an action is not in `inline_actions`.

**Right:** Return **403 Forbidden** with `review_url` in the error body:
```json
{
  "error": "action_not_inline",
  "message": "Action 'edit' requires the review page.",
  "review_url": "https://yourservice.com/review/abc123?token=..."
}
```

This lets the agent fall back to the browser flow gracefully. The human can complete the action on the full review page.

## JSON Schema Validation

Validate your HITL objects and poll responses:

- [hitl-object.schema.json](../../schemas/hitl-object.schema.json)
- [poll-response.schema.json](../../schemas/poll-response.schema.json)
- [form-field.schema.json](../../schemas/form-field.schema.json)
- [OpenAPI 3.1 spec](../../schemas/openapi.yaml)

## Compliance Tests

Run the test suites against your implementation:

- Node.js (Vitest): [tests/node/](../../tests/node/)
- Python (pytest): [tests/python/](../../tests/python/)


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)