# APR – Approval Forms for AI Agents

> Base URL: `https://apr.hoankim.xyz` · Version 0.1 (demo) · No authentication.
> APR lets an AI agent create a web form asking a human to review content and decide. The human opens the link, reads the content (Markdown or HTML), answers optional questions, and clicks one action button. APR then calls **your webhook, exactly as you specify it**, to deliver the result.

## TL;DR flow

1. `POST https://apr.hoankim.xyz/api/forms` with your `agent_code`, content, questions, buttons and webhook spec → you get `url`.
2. Send `url` to the human (Telegram, chat, email…).
3. Human submits → APR calls your webhook. On HTTP 2xx the form is **deleted** (one-time, final).
4. Lost track? `GET https://apr.hoankim.xyz/api/forms?agent_code=YOUR_CODE` lists your forms that are still open or waiting for delivery.

## Concepts

| Term | Meaning |
|---|---|
| `agent_code` | Your own code (e.g. `vorn`). Groups your forms. Use the same value every time. Pattern `[A-Za-z0-9_.-]{1,64}` |
| Form status | `open` → waiting for the human · `submitted` / `expired` / `cancelled` → result waiting for webhook delivery · `delivery_failed` → webhook failed after all retries (call `/retry`) |
| Lifetime | When your webhook returns 2xx the form is removed from the list. A summary is kept in the server audit log |
| Expiry | Default 7 days (`expires_in`, seconds, 60 … 7 776 000). On expiry APR calls your webhook with `event = form.expired` |

## 1. Create a form – `POST /api/forms`

Content-Type: `application/json`

| Field | Type | Req | Description |
|---|---|:-:|---|
| `agent_code` | string | ✔ | Your agent code |
| `title` | string | ✔ | Form title (header of the page) |
| `content` | object \| string | | `{ "format": "markdown" \| "html", "body": "..." }`. A plain string is treated as markdown. HTML is sanitized (no scripts) |
| `questions` | array | | List of questions (see below). May be empty |
| `actions` | array | ✔ | Buttons, at least 1 |
| `webhook` | object | ✔ | How APR must call you (see §3) |
| `recipient` | object | | Free info shown on the page, e.g. `{ "name": "Phạm Trung Kiên" }` |
| `metadata` | object | | Anything you want back in the webhook (e.g. task id) |
| `expires_in` | int | | Seconds, default 604800 (7 days) |

### Question object

| Field | Type | Description |
|---|---|---|
| `id` | string | Unique in the form, `[A-Za-z0-9_.-]` |
| `type` | `choice` \| `multi` \| `text` | `choice` = pick one (radio), `multi` = pick many (checkbox), `text` = free text |
| `label` | string | Question text |
| `options` | string[] | Required for `choice`/`multi`. **An "Khác" (Other) option with a text box is ALWAYS added automatically** – do not add it yourself |
| `other_label` | string | Optional label for the automatic Other option (default `Khác`) |
| `required` | bool | Must be answered for every button |
| `multiline` | bool | `text` only. Default `true` (textarea); `false` = single line |
| `placeholder`, `help` | string | Optional UI hints |

### Action (button) object

| Field | Type | Description |
|---|---|---|
| `id` | string | Returned to you in the webhook (e.g. `approve`, `reject`, `send`) |
| `label` | string | Button text (e.g. `Duyệt`, `Không duyệt`, `Gửi`) |
| `style` | `primary` \| `danger` \| `default` | Visual style |
| `requires` | string[] | Question ids that must be answered **when this button is clicked** (e.g. reason required for reject) |
| `confirm` | string | Optional confirmation prompt before submitting |

### Response `201`
```json
{ "id": "Xb3...", "url": "https://apr.hoankim.xyz/f/Xb3...", "status": "open", "agent_code": "vorn", "expires_at": "2026-10-14T10:00:00.000Z" }
```
Errors: `400 { "error": "..." }` with a precise message (field path).

## 2. Other endpoints

| Method & path | Purpose |
|---|---|
| `GET /api/forms?agent_code=X[&status=open]` | List your forms still stored (open or pending delivery) |
| `GET /api/forms/{id}` | Full form, status, result (if submitted) and delivery attempts. `404` = already delivered & deleted, or never existed |
| `DELETE /api/forms/{id}?agent_code=X` | Cancel an open form → webhook `form.cancelled` |
| `POST /api/forms/{id}/retry` | Re-send the webhook now (for `delivery_failed` or pending forms) |
| `POST /api/webhook/preview` | Body `{ "webhook": {...}, "metadata": {...}, "event": "form.submitted" }` → shows the exact HTTP request APR would send, **without sending**. Use it to debug your template |
| `GET /llms.md` | This document |

## 3. Webhook specification (you define it, APR follows it)

```json
"webhook": {
  "url": "https://your-agent.example.com/hooks/apr?form={{form_id}}",
  "method": "POST",
  "content_type": "json",
  "headers": { "Authorization": "Bearer abc", "X-Task": "{{metadata.ma_cv}}" },
  "query": { "event": "{{event}}" },
  "body": null,
  "timeout_ms": 10000
}
```

| Field | Default | Description |
|---|---|---|
| `url` | – | http(s) URL. Templates allowed. Private / loopback addresses are blocked |
| `method` | `POST` | `POST` \| `PUT` \| `PATCH` \| `GET` (GET sends no body) |
| `content_type` | `json` | `json` → JSON body · `form` → `application/x-www-form-urlencoded` (top-level keys; objects JSON-encoded) · `text` → plain text |
| `headers` | `{}` | Extra headers, values may use templates |
| `query` | `{}` | Extra query params, values may use templates |
| `body` | `null` | `null` → APR sends the **default payload** (below). Otherwise any JSON value used as a template |
| `timeout_ms` | 10000 | 1000 … 30000 |
| `signing` | auto | Name of a server-side signing profile (see §3.1), `"none"` to disable. If omitted, APR auto-applies the profile whose `url_prefix` matches `url` |

**Success** = HTTP 2xx. Otherwise APR retries after 1 min, 5 min, 30 min, 2 h, then marks `delivery_failed` (form is kept; use `/retry`). Redirects are not followed.

### 3.1 Signing (HMAC) – secrets stay on the server
Some receivers require signed requests. Secrets are **never** sent by agents: the APR operator registers a *signing profile* (secret in the server environment). List them with `GET /api/signing-profiles` (no secrets returned).

- Selection: `webhook.signing = "<profile>"`, or automatic when `webhook.url` starts with the profile's `url_prefix`.
- APR signs **each attempt** with a fresh timestamp: `signature = hex(HMAC_SHA256(secret, "<unix_seconds>.<raw_body>"))` (message format per profile), then adds the profile headers (e.g. `X-App-Id`, `X-Timestamp`, `X-Signature`).
- The body is sent byte-for-byte as signed (compact JSON). If the body exceeds the profile limit (e.g. 128 KB) delivery fails without retry benefit – keep `text` short.
- `POST /api/webhook/preview` shows the signed headers too.

Current profiles:

| Profile | url_prefix | Expected body |
|---|---|---|
| `kinex-relay` | `https://okiya-api.superunderwear.org/relay/channels/cell-kinex` | `{"schema":1,"message_id":"<unique ≤200>","text":"<content>"}` – e.g. `"message_id": "apr-{{form_id}}-{{event}}"` (stable across retries = idempotent) |

### Templates
- Write `{{path}}` inside any string of `url`, `headers`, `query`, `body`.
- If a string is **exactly** `"{{path}}"`, the raw value is inserted (object/array/number keep their type). Otherwise the value is converted to text (objects as JSON).
- Unknown paths become empty / `null`.

### Template variables

| Variable | Example | Notes |
|---|---|---|
| `event` | `form.submitted` | `form.submitted` \| `form.expired` \| `form.cancelled` |
| `form_id`, `form_url`, `title`, `agent_code` | | |
| `action.id`, `action.label` | `approve`, `Duyệt` | `null` for expired/cancelled |
| `answers` | array | `[{ id, label, type, value, other_text }]`. `value`: string (choice/text), string[] (multi), or `null`. If Other was chosen: `value = "Khác"`, `other_text` = typed text |
| `answers_map` | `{ "q1": "Tách", "q2": ["VT"] }` | id → value |
| `answers_map.<qid>` | `Tách` | Single answer |
| `answers_text` | `- Câu hỏi: Trả lời` lines | Human-readable summary (good for chat messages) |
| `metadata`, `metadata.<key>` | | What you passed at creation |
| `recipient`, `recipient.<key>` | | |
| `submitted_at`, `occurred_at` | ISO 8601 | |
| `client.ip`, `client.user_agent` | | Of the person who submitted |
| `payload` | object | The full default payload |

### Default payload (when `body` is `null`)
```json
{
  "event": "form.submitted",
  "form_id": "Xb3...",
  "agent_code": "vorn",
  "title": "Duyệt ma trận phân quyền",
  "form_url": "https://apr.hoankim.xyz/f/Xb3...",
  "action": { "id": "approve", "label": "Duyệt" },
  "answers": [
    { "id": "q1", "label": "Tách HCNS theo công ty?", "type": "choice", "value": "Khác", "other_text": "Tách từ 2027" },
    { "id": "q3", "label": "Ghi chú", "type": "text", "value": "OK" }
  ],
  "answers_map": { "q1": "Khác", "q3": "OK" },
  "metadata": { "ma_cv": "CV26100005" },
  "recipient": { "name": "Phạm Trung Kiên" },
  "submitted_at": "2026-10-07T10:00:00.000Z",
  "client": { "ip": "1.2.3.4", "user_agent": "..." },
  "occurred_at": "2026-10-07T10:00:01.000Z"
}
```
APR also adds headers `X-APR-Event` and `X-APR-Form-Id`.

### Example: custom body for a chat-style endpoint
```json
"webhook": {
  "url": "https://api.example.com/agents/vorn/messages",
  "headers": { "Authorization": "Bearer XYZ" },
  "body": {
    "role": "system",
    "text": "Form '{{title}}' → {{action.label}}\n{{answers_text}}",
    "data": "{{payload}}"
  }
}
```

## 4. Full example

```bash
curl -s -X POST https://apr.hoankim.xyz/api/forms -H 'Content-Type: application/json' -d '{
  "agent_code": "vorn",
  "title": "Duyệt ma trận phân quyền Drive v2",
  "recipient": { "name": "Phạm Trung Kiên" },
  "content": { "format": "markdown", "body": "## Tóm tắt\n- Thêm 6 nhóm mới\n- Tách HCNS/KT theo công ty\n\n| Nhóm | Quyền |\n|---|---|\n| BGD | Q |" },
  "questions": [
    { "id": "q1", "type": "choice", "label": "Tách HCNS theo công ty?", "options": ["Tách", "Giữ chung"], "required": true },
    { "id": "q2", "type": "multi", "label": "Nhóm cần tạo ngay", "options": ["HK-TN", "VT", "TV"] },
    { "id": "reason", "type": "text", "label": "Ý kiến / lý do" }
  ],
  "actions": [
    { "id": "approve", "label": "Duyệt", "style": "primary" },
    { "id": "reject", "label": "Không duyệt", "style": "danger", "requires": ["reason"], "confirm": "Xác nhận không duyệt?" }
  ],
  "metadata": { "ma_cv": "CV26100005" },
  "expires_in": 259200,
  "webhook": { "url": "https://your-endpoint.example.com/apr", "method": "POST" }
}'
```

## 5. Rules for agents
1. Always reuse the same `agent_code`; store the returned `id` together with your own task reference (or put it in `metadata`).
2. Keep `content` focused: what is being decided, key facts, consequences. Use Markdown tables for comparisons.
3. Do not add an "Other" option – APR adds it automatically.
4. Use `requires` to force a reason for negative actions.
5. Make your webhook idempotent (APR may retry). Return 2xx quickly.
6. Test your webhook template with `POST /api/webhook/preview` before creating real forms.
7. Demo service: no authentication. Anyone who knows a form link can submit it, and anyone who knows your `agent_code` can list your forms. Do not put secrets or sensitive personal data in forms.
