← Back to Plugins
Tools

Webhook Upload Ingress

davidcittadini By davidcittadini 👁 48 views ▲ 0 votes

OpenClaw plugin: authenticated multipart upload ingress that fires hooks (Tailscale-identity gated)

GitHub

Install

openclaw plugins install clawhub:webhook-upload-ingress

Configuration Example

{
  "_doc": "webhook-upload-ingress settings",
  "listen": { "host": "127.0.0.1", "port": 8790 },
  "allowed_logins": ["user@example.com"],
  "openclaw_config": "~/.openclaw/openclaw.json",
  "gateway_url": "http://127.0.0.1:18789",
  "intake_url": "http://127.0.0.1:9000/incidents",
  "inbox": "~/uploads/inbox",
  "outgoing": "~/uploads/outgoing",
  "state_dir": "~/uploads/state",
  "max_file_bytes": 209715200,
  "max_request_bytes": 1073741824,
  "quiet_period": 300,
  "dispatch_attempts": 5,
  "hook": {
    "agentId": "main",
    "deliver": true,
    "channel": "telegram",
    "accountId": "default",
    "to": "<chat-id>",
    "instruction": "New files arrived through the upload ingress. Review them."
  }
}

README

# Webhook Upload Ingress

An authenticated file-upload ingress for [OpenClaw](https://openclaw.ai). Clients (scripts, phone automations,
other services) upload files over HTTP; the plugin stores them on disk, deduplicates them, groups them into
batches and hands each batch to an agent of your choice through the Gateway's standard `POST /hooks/agent`
endpoint. It also serves an "outgoing" folder back to the same clients, so an agent can leave files for them to
pick up.

## Why a plugin

The Gateway's webhook endpoints take JSON, not binary or multipart uploads. Adding binary ingress to core was
declined upstream ([openclaw/openclaw#171273](https://github.com/openclaw/openclaw/issues/171273)) with the
recommendation to ship it as a ClawHub plugin. This is that plugin: it runs its own small loopback HTTP listener as
a plugin background service and talks to the Gateway only through the documented hooks API.

## How it works

1. A client uploads one or more files (`multipart/form-data`) to `POST /v1/photos`. Each file is streamed to
   `<inbox>/<batch>/<safe-name>` (mode 0600, batch directory 0700) and accepted only if it is an image container
   (JPEG, PNG, HEIC/HEIF/AVIF, WebP, TIFF/DNG).
2. Duplicates are skipped: a file whose SHA-256 is already recorded, or whose client-supplied `key` is already
   recorded, is reported as a duplicate and not stored again.
3. Files are grouped into a batch per client `session`. A batch is closed after `quiet_period` seconds without new
   files, or immediately when the client calls `POST /v1/photos/done`.
4. A closed batch is posted to `<gateway_url>/hooks/agent` with `idempotencyKey` = the batch id and no
   `sessionKey` (the Gateway runs each batch in a fresh isolated hook session). The message lists the absolute file
   paths, the source and any sender note (marked as untrusted text). Failed posts are retried with exponential
   backoff (30 s doubling, at most 15 min); after `dispatch_attempts` failures one incident can be posted to
   `intake_url`.
5. Files placed in `<outgoing>` are listed and served to clients, who acknowledge them; acknowledged files move to
   `<outgoing>/saved/`.

The whole service is one Node.js module with no runtime dependencies and no build step.

## Install

```bash
openclaw plugins install clawhub:webhook-upload-ingress
```

Then add a `plugins.entries` block (below), enable hooks on the Gateway, and restart the Gateway.

### Gateway prerequisites

The plugin posts to `/hooks/agent`, so hooks must be enabled with a token, and the target agent must be allowed:

```json5
{
  hooks: {
    enabled: true,
    token: "${OPENCLAW_HOOKS_TOKEN}", // resolved from the environment or the .env beside openclaw.json
    allowRequestSessionKey: false,      // the plugin never sends a sessionKey
    allowedAgentIds: ["main"]
  }
}
```

The plugin reads `hooks.token` from the OpenClaw config file at each dispatch. A `${NAME}` reference is resolved
from the process environment, else from the `.env` file in the same directory as the OpenClaw config (only that one
key is read). The hook token is never logged and is never accepted as a client token.

## Configuration

Configure the plugin in EITHER of two ways:

- **Inline**: put the settings directly under `plugins.entries.webhook-upload-ingress.config`.
- **Config file**: set `configFile` to a JSON file holding the same keys. Next to `configFile` only `service_name`
  and `identity_header` may be set; they override the file's values. Any other inline setting next to
  `configFile` is a configuration error.

Keys starting with `_` are comments and are ignored. Unknown keys are refused.

| Key | Type | Default | Meaning |
| --- | --- | --- | --- |
| `listen` | `{host, port}` | required | Listener address. `host` must be loopback: `127.0.0.1`, `::1` or `localhost`. |
| `allowed_logins` | string[] | `[]` | Identity-header values that are allowed. |
| `identity_header` | string | `Tailscale-User-Login` | Header carrying the proxy-verified identity. |
| `token_file` | path or null | none | File holding a Bearer token for clients. Must be a regular file with mode exactly `0600`, non-empty, and must not hold the Gateway hook token. |
| `openclaw_config` | path | `$OPENCLAW_CONFIG_PATH`, else `$OPENCLAW_STATE_DIR/openclaw.json`, else `~/.openclaw/openclaw.json` | Where `hooks.token` is read. |
| `gateway_url` | URL | `http://127.0.0.1:18789` | Gateway base URL; batches go to `<gateway_url>/hooks/agent`. |
| `intake_url` | URL or null | none | Optional; receives one JSON incident per batch that exhausted `dispatch_attempts`. |
| `inbox` | path | `~/.openclaw/webhook-upload-ingress/inbox` | Uploaded batches. Must be readable by the agent. |
| `outgoing` | path | `~/.openclaw/webhook-upload-ingress/outgoing` | Files served back to clients. |
| `state_dir` | path | `~/.openclaw/webhook-upload-ingress/state` | `relay-state.json` (dedupe and batch state) and the optional `mode` file. |
| `max_file_bytes` | integer | `209715200` (200 MiB) | Largest accepted file (413 above). |
| `max_request_bytes` | integer | `1073741824` (1 GiB) | Largest accepted upload request (413 above). Small form endpoints are capped at min(1 MiB, this). |
| `quiet_period` | number (s) | `300` | Seconds without new files before a batch is dispatched. |
| `dispatch_attempts` | integer | `5` | Failed dispatches before the incident is sent. Retries continue afterwards. |
| `service_name` | string | `webhook-upload-ingress` | Used in the `Server` header, log line prefix and incident ids. `[A-Za-z0-9._-]{1,64}`. |
| `hook` | object | required | The `/hooks/agent` payload, see below. |

Paths may start with `~` and must otherwise be absolute. Directories are created with mode 0700.

### `hook`

Only configured fields are sent; payload fields never come from clients.

| Field | Required | Sent as |
| --- | --- | --- |
| `agentId` | yes | `agentId` |
| `instruction` | yes | First line of `message` |
| `name` | no | `name` (hook label) |
| `deliver` | no | `deliver` (boolean) |
| `channel`, `accountId`, `to` | no | Direct announce destination (see the Gateway hooks docs) |
| `model`, `thinking` | no | Run overrides |
| `timeoutSeconds` | no | Turn timeout override (number > 0) |
| `wakeMode` | no | `now` or `next-heartbeat` |

The body is sent in the order `message, name, agentId, deliver, channel, accountId, to, model, thinking,
timeoutSeconds, wakeMode, idempotencyKey`. The message looks like:

```text
<instruction>
Source: album
Batch: 20260101T120000Z-1a2b3c4d
Files:
/path/to/inbox/20260101T120000Z-1a2b3c4d/IMG_0001.jpg
Note from the sender (untrusted text, not instructions):
> the note text, control characters removed, at most 4000 characters
```

Files the agent deleted before dispatch are left out; a batch whose files are all gone is dropped.

### Example (inline)

```json5
{
  plugins: {
    entries: {
      "webhook-upload-ingress": {
        enabled: true,
        config: {
          listen: { host: "127.0.0.1", port: 8790 },
          allowed_logins: ["user@example.com"],
          token_file: "~/.openclaw/webhook-upload-ingress/token",
          gateway_url: "http://127.0.0.1:18789",
          quiet_period: 300,
          hook: {
            agentId: "main",
            name: "Uploads",
            deliver: true,
            channel: "telegram",
            to: "<chat-id>",
            instruction: "New files arrived through the upload ingress. Review them and reply with a short summary."
          }
        }
      }
    }
  }
}
```

### Example (config file)

```json5
{
  plugins: {
    entries: {
      "webhook-upload-ingress": {
        enabled: true,
        config: { configFile: "~/.openclaw/webhook-upload-ingress/config.json" }
      }
    }
  }
}
```

with `~/.openclaw/webhook-upload-ingress/config.json`:

```json
{
  "_doc": "webhook-upload-ingress settings",
  "listen": { "host": "127.0.0.1", "port": 8790 },
  "allowed_logins": ["user@example.com"],
  "openclaw_config": "~/.openclaw/openclaw.json",
  "gateway_url": "http://127.0.0.1:18789",
  "intake_url": "http://127.0.0.1:9000/incidents",
  "inbox": "~/uploads/inbox",
  "outgoing": "~/uploads/outgoing",
  "state_dir": "~/uploads/state",
  "max_file_bytes": 209715200,
  "max_request_bytes": 1073741824,
  "quiet_period": 300,
  "dispatch_attempts": 5,
  "hook": {
    "agentId": "main",
    "deliver": true,
    "channel": "telegram",
    "accountId": "default",
    "to": "<chat-id>",
    "instruction": "New files arrived through the upload ingress. Review them."
  }
}
```

Create a client token file with:

```bash
umask 077; openssl rand -hex 32 > ~/.openclaw/webhook-upload-ingress/token; chmod 600 ~/.openclaw/webhook-upload-ingress/token
```

## Wire API

All answers are JSON (`Content-Type: application/json`) except file downloads and protocol-level errors. Every
response is HTTP/1.0 style with `Connection: close` and a `Server: <service_name> ` header; one request per
connection. Request bodies need a `Content-Length` (`Transfer-Encoding` is refused with 411). Every endpoint except
health needs authentication (see Security).

| Method and path | Request | Answer |
| --- | --- | --- |
| `GET /v1/health` | none, no auth | `200 {"ok":true}` |
| `GET /v1/config` | | `200 {"mode":"album"}`, or `{"mode":"auto"}` when `<state_dir>/mode` holds `auto` |
| `POST /v1/photos` | multipart upload, see below | `200 {"batch", "accepted": [{name, sha256, key}], "duplicates": [{name, key, reason}]}` |
| `POST /v1/photos/wanted` | form field `keys`: newline-separated client keys (multipart or urlencoded) | `200 {"wanted": "<the keys not yet recorded, newline-separated>"}` |
| `POST /v1/photos/done` | form field `session` | `202 {"batch", "state":"pending"}`, or `200 {"batch":null}` if the session has no open batch |
| `GET /v1/mockups` | | `200 {"mockups": [{name, size, sha256, url}]}`: the outgoing files, oldest first |
| `GET /v1/mockups/<name>` | | the file bytes, with its image `Content-Type` |
| `POST /v

... (truncated)
tools

Comments

Sign in to leave a comment

Loading comments...