Install
openclaw skills install @psyb0t/hybrids3Self-hosted lightweight object storage speaking three interfaces at once — the S3 API (boto3 / AWS-SDK compatible, AWS Sig V4), a plain HTTP API (curl-friendly, Bearer auth), and MCP (Streamable HTTP tools for agents). Buckets/objects with SQLite metadata and flat-file storage on disk; buckets are config-defined (no create/delete API), each with a public/private flag, a private key + public key pair, optional TTL expiry, and a max upload size. Auth is per-bucket keys plus a cross-bucket master key; presigned GET/PUT URLs for handing out single-object access. Use when the user wants to put/get/list/delete files in a self-hosted S3-compatible store, generate presigned upload/download links, drive their own boto3-compatible object storage, or connect an agent to object storage over MCP.
openclaw skills install @psyb0t/hybrids3Lightweight object storage that speaks three interfaces from one container: the S3 API (boto3 / any AWS SDK, AWS Signature V4), a plain HTTP API (curl + Bearer token), and MCP (Streamable HTTP tool definitions for AI agents). Metadata lives in SQLite, object bytes are flat files on disk.
Buckets are configuration, not state — they're defined in the server's config.yaml and there is no API to create or delete them. Each bucket carries: a public/private flag, a private key (Bearer token + S3 signing secret), a public_key (the aws_access_key_id identifier), an optional ttl (objects auto-expire after last write), and an optional max_file_size. A single cross-bucket master_key operates on every bucket and is the only credential that can list all buckets.
Object keys support nested paths (reports/2024/jan.pdf). Content type is auto-detected on upload (libmagic sniff + extension fallback). Presigned GET/PUT URLs hand out time-limited single-object access without exposing a key.
For installation, configuration, and container setup, see references/setup.md.
public: true serves GET/HEAD/LIST to anyone who can reach the port — no auth at all. Only store data there that's meant to be openly readable; keep sensitive data in a public: false bucket.8080. Publishing it as -p 8080:8080 / compose "8080:8080" makes it reachable from the network by default. Use -p 127.0.0.1:8080:8080 (loopback) unless remote access is intentionally required, and for real deployments put the service on an internal Docker network behind a reverse proxy instead of exposing the port directly — see references/setup.md and Behind a Reverse Proxy.DELETE, presigned PUT, and MCP delete_object are destructive & irreversible. There's no undo, no versioning, no recycle bin — a deleted object (or one overwritten via PUT) is gone. An agent must NEVER call DELETE / delete_object, or generate/use a presigned PUT that overwrites, unless the user explicitly asked for that exact key to be removed or replaced; confirm the specific bucket + key first, never enumerate-then-bulk-delete, and treat bulk cleanup as requiring explicit per-object user confirmation.key or the master_key (or a live presigned URL) can act as that bucket/master until the credential is rotated or the URL expires — there's no per-caller revocation. Never paste these into shared prompts, logs, or presigned URLs handed to untrusted parties beyond the one object they're meant to grant.config.yaml, change bucket visibility, or alter port bindings on the user's behalf without being explicitly asked.endpoint_url + credentials, nothing else).upload_object / download_object / list_objects / etc. tools.ttl and objects clean themselves up.config.yaml; add/remove them there and restart. PUT /{bucket} is an S3-compat no-op.download_object tool caps at 50 MB. Use the S3 or plain HTTP GET for large objects.The container should already be running. Point the skill at it:
export HYBRIDS3_URL=http://localhost:8080
Verify: curl $HYBRIDS3_URL/health returns {"status":"ok"}.
Keys are per-bucket and come from the operator's config.yaml. Export the ones you'll use rather than pasting them inline:
export HYBRIDS3_KEY=<bucket-private-key> # Bearer token / aws_secret_access_key
export HYBRIDS3_PUBLIC_KEY=<bucket-public-key> # aws_access_key_id (defaults to bucket name)
# For cross-bucket / list-all-buckets operations, use the master credentials instead:
export HYBRIDS3_MASTER_KEY=<master-key>
export HYBRIDS3_MASTER_PUBLIC_KEY=<master-public-key> # defaults to "master"
If the server runs behind a reverse proxy at a subpath, HYBRIDS3_URL includes it (e.g. http://host/storage) and every route — health included — lives under that prefix.
For install / config.yaml shape / bucket + key definitions / ports / reverse proxy, see references/setup.md.
One service, three front doors on the same port (8080). Pick per client:
| Mode | Endpoint | Auth | Reach for it when… |
|---|---|---|---|
| S3 API | root (/), signed requests | AWS Sig V4 (public_key = access key id, key = secret) | You already have boto3 / AWS-SDK code, or want SDK ergonomics (pagination, presign helpers, retries). |
| Plain HTTP | /{bucket}/{key} etc. | Authorization: Bearer <key> | Shell scripts, curl, quick one-offs. No SDK, no signing — just a Bearer header (and public-bucket reads need none). |
| MCP | POST /mcp/ | per-connection + per-tool auth_key | An AI agent should manipulate storage through typed tool calls instead of raw HTTP. |
They share one SQLite metadata store and one flat-file tree — an object written over S3 is readable over plain HTTP and MCP, and vice versa.
S3 clients authenticate with AWS Signature V4. The bucket's public_key is the aws_access_key_id (travels in the request in plaintext, in the Credential= field); the bucket's private key is the aws_secret_access_key (used only to compute the HMAC locally — never transmitted). region_name is required by SDKs but the value is arbitrary — use us-east-1.
Authorization: AWS4-HMAC-SHA256 Credential=uploads-id/20240101/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-date, Signature=<hmac>
import boto3
from botocore.config import Config
# per-bucket client — access limited to the "uploads" bucket
s3 = boto3.client(
"s3",
endpoint_url="http://localhost:8080", # HYBRIDS3_URL
aws_access_key_id="uploads-id", # bucket public_key
aws_secret_access_key="uploads-secret", # bucket key (private)
region_name="us-east-1", # arbitrary but required
config=Config(signature_version="s3v4"),
)
s3.put_object(Bucket="uploads", Key="file.txt", Body=b"hello")
s3.get_object(Bucket="uploads", Key="file.txt")["Body"].read()
s3.head_object(Bucket="uploads", Key="file.txt")
s3.list_objects_v2(Bucket="uploads", Prefix="images/")
s3.delete_object(Bucket="uploads", Key="file.txt")
s3.list_buckets() # bucket key → only "uploads"
# presigned URLs (see MCP / HTTP presign for the same semantics)
get_url = s3.generate_presigned_url(
"get_object", Params={"Bucket": "permanent", "Key": "doc.pdf"}, ExpiresIn=3600,
)
put_url = s3.generate_presigned_url(
"put_object", Params={"Bucket": "uploads", "Key": "inbox/report.pdf"}, ExpiresIn=600,
)
For cross-bucket access or list_buckets() returning all buckets, build a second client with the master credentials (aws_access_key_id=master_public_key, aws_secret_access_key=master_key). Don't embed the master key in client-facing code — per-bucket keys scope access to one bucket.
Destructive & irreversible. delete_object deletes the object with no undo — no versioning, no recycle bin. An agent must NEVER call it unless the user explicitly asked for that exact key to be deleted; confirm the specific bucket + key first, scope it to the current task, and never enumerate-then-bulk-delete. On a shared instance where multiple callers use the same bucket, this can destroy another caller's data — treat bulk deletes as admin-only and requiring explicit confirmation.
aws --endpoint-url "$HYBRIDS3_URL" \
--region us-east-1 \
s3api put-object --bucket uploads --key file.txt --body ./file.txt
# credentials via env: AWS_ACCESS_KEY_ID=<public_key> AWS_SECRET_ACCESS_KEY=<key>
Supported S3 operations: put_object, get_object, head_object, delete_object, list_objects_v2 (honours Prefix), list_buckets, generate_presigned_url (GET + PUT). Not supported: multipart upload, versioning, ACLs, bucket create/delete (boto3's implicit create_bucket maps to a no-op).
Bearer auth: pass the bucket's private key (or the master_key) as Authorization: Bearer <key>. Public-bucket reads (GET/HEAD/LIST) need no auth; all writes and private-bucket reads require a key.
Requests carrying an AWS Sig V4 Authorization header get S3-style XML back; everything else gets JSON. Every response includes X-Request-Id (log correlation) and X-Content-Type-Options: nosniff. Errors carry "error" + "request_id" fields.
| Method | Path | Auth | Description |
|---|---|---|---|
PUT | /{bucket}/{key} | write | Upload object. Returns ETag (MD5 of content). Content type auto-detected; override with a Content-Type header. |
GET | /{bucket}/{key} | read | Download object. Returns ETag, Last-Modified, Content-Length. |
HEAD | /{bucket}/{key} | read | Object metadata, no body. |
DELETE | /{bucket}/{key} | write | Delete object — always 204, even if absent. |
GET | /{bucket} | read | List objects. Query: prefix, max-keys. |
POST | /presign/{bucket}/{key} | write | Generate a presigned URL. Query: method (GET default / PUT), expires (seconds, 1–604800, default 3600). |
Destructive & irreversible. DELETE removes the object with no undo. An agent must NEVER call it unless the user explicitly asked for that exact key to be deleted; confirm the specific bucket + key first, scope it to the current task, and never enumerate-then-bulk-delete. On a shared bucket used by other callers, this can destroy their data — treat it as admin-only.
# upload
curl -X PUT "$HYBRIDS3_URL/uploads/file.txt" \
-H "Authorization: Bearer $HYBRIDS3_KEY" \
--data-binary @file.txt
# upload a nested key (parent dirs created automatically)
curl -X PUT "$HYBRIDS3_URL/uploads/reports/2024/january.pdf" \
-H "Authorization: Bearer $HYBRIDS3_KEY" \
--data-binary @january.pdf
# download from a public bucket — no auth needed
curl "$HYBRIDS3_URL/uploads/file.txt"
# download from a private bucket
curl "$HYBRIDS3_URL/permanent/doc.pdf" \
-H "Authorization: Bearer $HYBRIDS3_KEY"
# list objects with a prefix
curl "$HYBRIDS3_URL/uploads?prefix=images/&max-keys=50" \
-H "Authorization: Bearer $HYBRIDS3_KEY"
# delete
curl -X DELETE "$HYBRIDS3_URL/uploads/file.txt" \
-H "Authorization: Bearer $HYBRIDS3_KEY"
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /health | none | {"status":"ok"} liveness check. |
GET | / | master or bucket key | List buckets — master key lists all, bucket key lists only its own. |
HEAD | /{bucket} | read | 200 if bucket exists in config, else 404. |
PUT | /{bucket} | write | S3-compat no-op: 200 if bucket exists in config, 404 if not. Creates nothing. |
# list all buckets — needs the master key
curl "$HYBRIDS3_URL/" -H "Authorization: Bearer $HYBRIDS3_MASTER_KEY"
# a bucket key lists only its own bucket
curl "$HYBRIDS3_URL/" -H "Authorization: Bearer $HYBRIDS3_KEY"
POST /presign/{bucket}/{key} requires the bucket key or master key. The result grants exactly one verb on exactly one key. A GET URL can't be used to PUT (the method is baked into the signature).
# presigned GET on a PRIVATE bucket → signed, expiring URL
curl -X POST "$HYBRIDS3_URL/presign/permanent/doc.pdf?expires=3600" \
-H "Authorization: Bearer $HYBRIDS3_KEY"
# → {"url":"http://.../permanent/doc.pdf?X-Amz-Algorithm=...&X-Amz-Signature=...","method":"GET","expires":3600}
# presigned GET on a PUBLIC bucket → plain URL, no signature, no expiry (reads are open anyway)
curl -X POST "$HYBRIDS3_URL/presign/uploads/photo.jpg" \
-H "Authorization: Bearer $HYBRIDS3_KEY"
# → {"url":"http://.../uploads/photo.jpg","method":"GET","expires":null}
# presigned PUT → ALWAYS signed, even for public buckets (anonymous writes are never allowed)
curl -X POST "$HYBRIDS3_URL/presign/uploads/inbox/report.pdf?method=PUT&expires=600" \
-H "Authorization: Bearer $HYBRIDS3_KEY"
# recipient uploads using only the URL — no Authorization header
curl -X PUT "<presigned-put-url>" --data-binary @report.pdf
The bucket's max_file_size is enforced server-side during the upload regardless of how it was authenticated (oversized → 413). Expired or tampered presigned URLs → 403.
An MCP server runs at POST /mcp/ over the Streamable HTTP transport. Client config:
{
"mcpServers": {
"hybrids3": { "type": "streamable-http", "url": "http://localhost:8080/mcp/" }
}
}
Wire it into Claude Code:
claude mcp add --transport http hybrids3 "$HYBRIDS3_URL/mcp/"
# with endpoint-level auth:
claude mcp add --transport http hybrids3 "$HYBRIDS3_URL/mcp/" \
--header "Authorization: Bearer $HYBRIDS3_MASTER_KEY"
Endpoint-level (optional) — a token that authenticates the connection before any tool runs. Master key = full access; a bucket key = limited to that bucket. Send it as Authorization: Bearer <key>, or (for clients that can't set headers) as ?auth=<key> on the URL. A token that matches nothing → 401; no token → passes through, and per-tool auth applies.
Per-tool — every tool that touches a bucket takes an auth_key argument (the bucket's private key or the master key), checked independently of the connection token. Public-bucket reads (download_object, list_objects, object_info) accept an empty auth_key; all writes and all private-bucket ops require it.
| Tool | Args | Auth | Notes |
|---|---|---|---|
upload_object | bucket, key, content, auth_key, content_type="", encoding="utf-8" | bucket/master key | encoding="base64" for binary. Returns size, etag, content_type. |
download_object | bucket, key, auth_key="", encoding="utf-8" | key on private only | base64 for binary; UTF-8 that won't decode auto-falls back to base64. Objects > 50 MB rejected — use HTTP. |
delete_object | bucket, key, auth_key | bucket/master key | Idempotent. |
list_objects | bucket, auth_key="", prefix="", max_keys=100 | key on private only | max_keys clamped to 1–1000. |
list_buckets | auth_key | master or bucket key | Master lists all; bucket key lists only its own. |
object_info | bucket, key, auth_key="" | key on private only | Metadata only (size, content_type, etag, uploaded_at, expires_at). |
presign_url | bucket, key, auth_key, method="GET", expires=3600, base_url="", host="" | bucket/master key | method GET/PUT. Same public-GET-plain / everything-else-signed logic as the HTTP endpoint. expires clamped 1–604800. Set base_url to the externally-reachable URL so the link is usable. |
delete_object is destructive & irreversible. It deletes the object with no undo. An agent must NEVER call it unless the user explicitly asked for that exact key to be deleted; confirm the specific bucket + key first, never enumerate-then-bulk-delete. On a shared bucket used by other callers, this can destroy their data — treat it as admin-only.
All tools return structuredContent with a plain-text fallback. Internal error details are masked.
For debugging or non-MCP callers, POST JSON-RPC directly. The transport requires the Accept header below.
# tools/list
curl -s "$HYBRIDS3_URL/mcp/" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
# tools/call — upload
curl -s "$HYBRIDS3_URL/mcp/" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{
"jsonrpc":"2.0","id":2,"method":"tools/call",
"params":{
"name":"upload_object",
"arguments":{"bucket":"uploads","key":"note.txt","content":"hello","auth_key":"'"$HYBRIDS3_KEY"'"}
}
}'
| Credential | Config field | Role | Keep secret? |
|---|---|---|---|
| Bucket private key | key | Bearer token (HTTP/MCP) and aws_secret_access_key (S3). Signs/verifies — never transmitted. | Yes |
| Bucket public key | public_key | aws_access_key_id (S3) + Credential= in presigned URLs. Identifies, grants nothing. Defaults to bucket name. | No |
| Master key | master_key | Cross-bucket god key. Works on every bucket + only credential that lists all buckets. | Yes |
| Master public key | master_public_key | aws_access_key_id paired with the master key (S3). Defaults to "master". | No |
Bucket visibility:
| GET / HEAD / LIST | PUT | DELETE / presign | |
|---|---|---|---|
public: true | no auth | bucket key, master key, or valid presigned PUT | bucket key or master key |
public: false | bucket key, master key, or valid presigned GET | bucket key, master key, or valid presigned PUT | bucket key or master key |
Unauthorized or non-existent bucket access always returns 404 — callers can't tell "doesn't exist" from "no access". All key comparisons are constant-time.
export HYBRIDS3_URL=http://localhost:8080
export HYBRIDS3_KEY=<uploads-key>
curl -X PUT "$HYBRIDS3_URL/uploads/report.pdf" \
-H "Authorization: Bearer $HYBRIDS3_KEY" --data-binary @report.pdf
curl -sO "$HYBRIDS3_URL/uploads/report.pdf" # public bucket → no auth on read
Or via the helper (see scripts/hybrids3.sh):
HYBRIDS3_URL=http://localhost:8080 HYBRIDS3_KEY=<key> \
scripts/hybrids3.sh put uploads report.pdf ./report.pdf
scripts/hybrids3.sh list uploads reports/
scripts/hybrids3.sh get uploads report.pdf ./out.pdf
scripts/hybrids3.sh delete uploads report.pdf
scripts/hybrids3.sh buckets # needs HYBRIDS3_KEY=<master-key>
# you generate it (needs a key); they upload with no credentials
curl -X POST "$HYBRIDS3_URL/presign/uploads/inbox/theirs.zip?method=PUT&expires=600" \
-H "Authorization: Bearer $HYBRIDS3_KEY" | jq -r .url
# → hand the URL over; recipient: curl -X PUT "<url>" --data-binary @theirs.zip
Point any existing boto3 code at HYBRIDS3_URL, set the bucket's public_key/key as the credential pair, region_name="us-east-1", signature_version="s3v4". See S3 API.
Add the server (claude mcp add --transport http hybrids3 "$HYBRIDS3_URL/mcp/"), then call list_buckets to discover buckets, upload_object / download_object / list_objects to move data. Pass the bucket key as auth_key; use the master key only when you need cross-bucket reach.
If a bucket has a ttl set (server config), objects delete themselves that long after their last write — overwriting resets the clock. Nothing to do client-side; just write to the bucket.