A stateless FastAPI bridge that receives audio webhooks from the Pebble Index 01 wearable and forwards them to a Hermes (or any OpenAI-compatible) LLM server — so every voice note you record on the ring gets processed by your own AI, running on your own hardware.
┌─────────────────┐
│ Pebble Index 01 │ ─┐
│ (the ring) │ │ multipart/form-data ┌──────────────────────┐
└─────────────────┘ ├── POST /webhook ────────►│ index-hermes-webhook │
┌─────────────────┐ │ ┌───-│ (this service) │
│ Apple Shortcuts │ ─┘ 202 Accepted ◄─────┘ └────────┬──────┬──────┘
└─────────────────┘ background│task │
┌────────────┘ │
if audio only │ POST /api/jobs
│ │ POST /api/jobs/{id}/run
▼ ▼
┌──────────────────┐ ┌──────────────────────┐
│ Whisper STT │ │ Hermes API server │
│ /audio/trans. │ │ (job scheduling) │
└──────────────────┘ └──────────────────────┘
- You speak into your Pebble Index 01. The ring transcribes your voice and POSTs the result to a configured webhook URL.
- This service validates the request and immediately returns 202 Accepted — the connection is never held open.
- A background task takes over: if only raw audio was received it calls Whisper to transcribe it first, then schedules a one-time Hermes job with the note payload.
- Hermes runs the job and delivers the response to the configured channel (Telegram by default).
The service is completely stateless — no database, no file writes, no disk. Every request lives and dies in memory.
| Requirement | Notes |
|---|---|
| Pebble Index 01 | Firmware with webhook support (Advanced Features → Webhook) |
| Hermes API server | Any OpenAI-compatible server; Nous Hermes Agent runs on port 8642 by default |
| Docker (recommended) | Or Python 3.11+ with uv |
| Public HTTPS URL | The Pebble app requires HTTPS. Use ngrok, Cloudflare Tunnel, or a reverse proxy. |
git clone https://github.com/<your-org>/index-hermes-webhook
cd index-hermes-webhook
cp .env.example .envEdit .env — at minimum set:
HERMES_BASE_URL=http://your-hermes-host:8642
HERMES_SYSTEM_PROMPT=You are a personal assistant. Summarize the voice note concisely.docker compose up --buildThe service starts on http://localhost:8000. Verify it:
curl http://localhost:8000/health
# {"status":"ok"}Use your preferred tunnelling tool, for example:
ngrok http 8000
# → https://abc123.ngrok-free.appIn the Pebble mobile app:
- Open Index → Advanced Features
- Enable Webhook and paste your public URL:
https://abc123.ngrok-free.app/webhook - Set Mode to Both (sends transcription + audio) or Text only (transcription only — cheaper/faster)
- Under Custom Headers, add:
This is how Hermes knows which ring sent the note. If you have only one ring, any static name works.
X-Ring-ID: <a name you choose, e.g. "living-room-ring"> - (Optional) To secure the endpoint, also add:
— and set the same value as
Authorization: Bearer <your-WEBHOOK_AUTH_TOKEN>WEBHOOK_AUTH_TOKENin your.env
No ring? Use the ready-made shortcut to record audio from any Apple device and POST it to this webhook in the same multipart format:
Open the shortcut, set your webhook URL (and optionally an auth token), then run it whenever you want to capture a voice note. The shortcut sends the raw audio file; Whisper handles the transcription, so WHISPER_BASE_URL must be configured.
All settings are environment variables. Copy .env.example to .env for local development; pass them directly in Docker / Kubernetes for production.
| Variable | Default | Description |
|---|---|---|
HERMES_BASE_URL |
http://localhost:8642 |
Base URL of the Hermes (OpenAI-compatible) server |
HERMES_API_KEY |
(empty) | Bearer token for the Hermes server (API_SERVER_KEY in Hermes config) |
HERMES_MODEL |
hermes-agent |
Model name sent in requests (ignored by Hermes unless direct_model_requests: true) |
HERMES_SYSTEM_PROMPT |
See .env.example |
The predefined prompt — injected as the system message before every transcription |
HERMES_MAX_TOKENS |
1024 |
Maximum tokens in the LLM response |
HERMES_TEMPERATURE |
0.7 |
Sampling temperature |
HERMES_TIMEOUT_SECONDS |
30.0 |
HTTP timeout for Hermes calls |
Only needed when the Pebble is configured to send audio only (no transcription). Leave blank if using Text or Both mode.
| Variable | Default | Description |
|---|---|---|
WHISPER_BASE_URL |
(empty) | OpenAI-compatible Whisper endpoint (e.g. http://localhost:8080) |
WHISPER_API_KEY |
(empty) | Bearer token for the Whisper server |
WHISPER_MODEL |
whisper-1 |
Whisper model name |
| Variable | Default | Description |
|---|---|---|
WEBHOOK_AUTH_TOKEN |
(empty) | If set, the webhook endpoint requires Authorization: Bearer <token>. Configure the same token as a Custom Header in the Pebble app. |
| Variable | Default | Description |
|---|---|---|
LOG_LEVEL |
INFO |
Python log level (DEBUG, INFO, WARNING, ERROR) |
PORT |
8000 |
Port the server binds to inside the container |
The Pebble sends a multipart/form-data POST with the following fields:
| Field | Type | When present |
|---|---|---|
transcription |
string |
Text and Both modes |
audio |
audio/mp4 (M4A) |
Audio and Both modes |
recordedAt |
Unix ms timestamp | Always |
client |
string — always "ring" |
Always |
test |
"true" |
Only on test events sent from the Pebble app |
In addition, the service reads these custom HTTP headers:
| Header | Purpose |
|---|---|
X-Ring-ID |
Identifies which ring sent the webhook. Set this as a Custom Header in the Pebble app (see Setup). |
X-Channel-ID |
Explicit Hermes delivery channel (e.g. telegram:12345678). Takes precedence over X-Telegram-ID. |
X-Telegram-ID |
Telegram user/chat ID. If set (and X-Channel-ID is absent), the job is delivered to telegram:<id>. Falls back to telegram when both headers are absent. |
This service prioritises transcription if present. If only audio is received, it is forwarded to the configured Whisper endpoint. If neither is present, HTTP 400 is returned.
Each webhook call creates a new one-time Hermes job named ring-<ring_id> (e.g. ring-my-ring). The name is the same for every note from the same ring, which makes jobs identifiable in Hermes logs and UI, but each call is an independent ephemeral job — not an update to an existing one.
The job's prompt field is a tagged message:
<instructions>
{HERMES_SYSTEM_PROMPT}
</instructions><ring_payload>
{"transcription": "Buy oat milk on the way home", "ring_id": "my-ring", ...}
</ring_payload>
All non-audio fields plus X-Ring-ID are included in ring_payload. The audio binary is always dropped. Fields are omitted when absent.
Note:
test: trueis included when the Pebble sends a test event so your prompt can instruct Hermes to ignore or acknowledge it differently.
Your HERMES_SYSTEM_PROMPT should tell the model how to interpret these fields — for example:
You are a personal assistant processing voice notes from a smart ring.
"ring_id" identifies the specific ring that recorded the note.
"recorded_at" is a Unix millisecond timestamp of when the note was recorded.
If "test" is true, acknowledge the test but do not act on the content.
If your Hermes agent wraps cron-job responses in extra formatting, add this to its agent config to keep replies natural:
cron:
wrap_response: falsedocker pull ghcr.io/<your-org>/index-hermes-webhook:latest
docker run -d \
-p 8000:8000 \
-e HERMES_BASE_URL=http://your-host:8642 \
-e HERMES_SYSTEM_PROMPT="Summarize this voice note." \
-e WEBHOOK_AUTH_TOKEN=supersecret \
ghcr.io/<your-org>/index-hermes-webhook:latestdocker build -t index-hermes-webhook .Multi-arch (amd64 + arm64):
docker buildx build --platform linux/amd64,linux/arm64 -t index-hermes-webhook .GitHub Actions workflow at .github/workflows/docker.yml automatically:
- Builds on every pull request (no push)
- Builds and pushes to
ghcr.io/<owner>/<repo>on every push tomain - Tags releases by semver when you push a
v*tag - Uses layer caching via GitHub Actions Cache for fast rebuilds
- Produces multi-arch images (
linux/amd64+linux/arm64)
No secrets to configure — the workflow uses GITHUB_TOKEN (automatically provided by GitHub).
To release a new version:
git tag v1.2.3
git push origin v1.2.3# Install uv if you don't have it
curl -LsSf https://astral.sh/uv/install.sh | sh
# Create venv and install deps
uv sync
# Run dev server with auto-reload
uv run uvicorn app.main:app --reload
# Run tests
uv run pytestcurl -X POST http://localhost:8000/webhook \
-F "transcription=Buy oat milk on the way home" \
-F "recordedAt=1700000000000" \
-F "client=ring"| Decision | Rationale |
|---|---|
| FastAPI | Async, typed, zero-boilerplate multipart form handling via UploadFile + Form |
| httpx (async) | Non-blocking calls to Hermes and Whisper; respects the FastAPI async model |
| pydantic-settings | Single source of truth for config; reads ENV vars and .env files automatically |
| No database / no disk writes | The Pebble ring can store notes itself; this service is a pure processing pipeline |
| Whisper as optional path | Most users configure the ring in Text/Both mode — adding a mandatory STT dependency would complicate deployment for the common case |
| GHCR over Docker Hub | Free for public repos, auth is GITHUB_TOKEN — zero extra secrets to manage |
MIT