openscribe/api/openapi.yaml
Laurence 031074c9a9
Some checks failed
ci / firmware (push) Failing after 2s
ci / server (push) Failing after 45s
scaffold: OpenScribe open-source self-hosted AI voice recorder
Bootstrap of the project (M0). Sets up the monorepo, design docs, hardware BOM,
the open API contract, component skeletons, licensing and CI, following the
Default Workflow SOP.

What changed:
- CLAUDE.md + docs/: copied the Default Workflow so sessions load the SOP.
- state/: PROJECT, ARCHITECTURE, DECISIONS, TODO, NOTES filled in for OpenScribe.
  ARCHITECTURE captures the four-part design (firmware, server, app, case) and the
  three sync paths; DECISIONS records the hardware, AI-stack, storage, app and
  licensing choices; TODO lays out milestones M1-M9.
- hardware/BOM.md: two build options (compact XIAO ESP32-S3 Sense; dev ESP32-S3 +
  I2S mic + SD), wiring/pinout, indicative cost.
- api/openapi.yaml: the completely open API (device + server surfaces), including
  recording list/download/delete and exports (wav/ogg/txt/srt/vtt/md/json).
- firmware/: PlatformIO ESP32-S3 project, two board profiles, pin map, boot scaffold
  with module seams for M1-M4.
- server/: FastAPI skeleton mirroring the OpenAPI, config for self-hosted MinIO,
  faster-whisper and Ollama; stub routes browsable at /docs.
- app/, case/: Flutter app plan; parametric OpenSCAD enclosure.
- Licensing: GPL-3.0 (code), CERN-OHL-S-2.0 (hardware), CC-BY-SA-4.0 (case/docs),
  REUSE-style LICENSES/ with SPDX headers; LICENSING.md explains the split.
- CI: Forgejo Actions workflow builds firmware (both profiles) and lints/imports server.

Why:
- Everything self-hosted and openly licensed per the user's requirements: an open
  API, three sync paths (BLE control, WiFi transfer, independent WiFi upload on
  charge to generic cloud storage), and a full self-hosted transcription+summary stack.

Notes:
- No custom PCB in v1; off-the-shelf modules. Physical verification waits on parts.
- Component code is stubs at M0; features land milestone by milestone, each as its
  own branch/PR per the workflow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 10:21:37 +01:00

363 lines
No EOL
12 KiB
YAML

# SPDX-License-Identifier: CC-BY-SA-4.0
# OpenScribe open API - single source of truth for the device and server REST interfaces.
# The device implements the "device" paths on the LAN; the server implements the "server"
# paths. Both share the Recording schema. Keep firmware and server in sync with this file.
openapi: 3.1.0
info:
title: OpenScribe API
version: 0.1.0
description: >
Open API for the OpenScribe self-hosted AI voice recorder. Two surfaces share this
document: the on-device REST API (served by the ESP32-S3 on the LAN) and the
self-hosted server API (ingest, transcription, summaries, exports). Everything the
owner needs to list, download, export and delete their recordings is here.
license:
name: CC-BY-SA-4.0
identifier: CC-BY-SA-4.0
servers:
- url: http://openscribe.local/api/v1
description: Device on the LAN (mDNS name; also reachable by IP)
- url: http://your-server.lan:8000/api/v1
description: Self-hosted server
tags:
- name: device
description: Implemented by the firmware on the device (LAN).
- name: server
description: Implemented by the self-hosted server.
- name: recordings
description: Recording listing, download, export, delete (both surfaces where noted).
security:
- bearerAuth: []
paths:
# ---------------- Device surface ----------------
/device:
get:
tags: [device]
summary: Device status
responses:
"200":
description: Current device status
content:
application/json:
schema: { $ref: "#/components/schemas/DeviceStatus" }
/device/config:
get:
tags: [device]
summary: Read device config (secrets redacted)
responses:
"200":
description: Config
content:
application/json:
schema: { $ref: "#/components/schemas/DeviceConfig" }
put:
tags: [device]
summary: Update device config (WiFi, upload target, codec)
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/DeviceConfigUpdate" }
responses:
"200": { description: Updated }
"401": { $ref: "#/components/responses/Unauthorized" }
/record/start:
post:
tags: [device]
summary: Start a recording
responses:
"200":
description: Recording started
content:
application/json:
schema: { $ref: "#/components/schemas/Recording" }
"409": { description: Already recording }
/record/stop:
post:
tags: [device]
summary: Stop the current recording
responses:
"200":
description: Recording stopped
content:
application/json:
schema: { $ref: "#/components/schemas/Recording" }
"409": { description: Not recording }
/ota:
post:
tags: [device]
summary: Upload a firmware image to update the device (OTA)
requestBody:
required: true
content:
application/octet-stream:
schema: { type: string, format: binary }
responses:
"202": { description: Update accepted; device will apply and reboot }
"401": { $ref: "#/components/responses/Unauthorized" }
# ---------------- Shared: recordings ----------------
/recordings:
get:
tags: [recordings]
summary: List recordings
description: Implemented by both device (local files) and server (ingested).
parameters:
- { in: query, name: limit, schema: { type: integer, default: 50 } }
- { in: query, name: cursor, schema: { type: string } }
- in: query
name: sync_state
schema: { $ref: "#/components/schemas/SyncState" }
responses:
"200":
description: A page of recordings
content:
application/json:
schema: { $ref: "#/components/schemas/RecordingPage" }
/recordings/{id}:
parameters:
- { in: path, name: id, required: true, schema: { type: string } }
get:
tags: [recordings]
summary: Get recording metadata
responses:
"200":
description: Recording metadata
content:
application/json:
schema: { $ref: "#/components/schemas/Recording" }
"404": { $ref: "#/components/responses/NotFound" }
delete:
tags: [recordings]
summary: Delete a recording (and its artefacts)
responses:
"204": { description: Deleted }
"404": { $ref: "#/components/responses/NotFound" }
/recordings/{id}/audio:
parameters:
- { in: path, name: id, required: true, schema: { type: string } }
get:
tags: [recordings]
summary: Download the audio (supports HTTP Range for resumable/partial fetch)
responses:
"200":
description: Full audio
content:
audio/wav: { schema: { type: string, format: binary } }
audio/ogg: { schema: { type: string, format: binary } }
"206": { description: Partial content (Range) }
"404": { $ref: "#/components/responses/NotFound" }
# ---------------- Server surface: AI + exports ----------------
/recordings/{id}/transcript:
parameters:
- { in: path, name: id, required: true, schema: { type: string } }
get:
tags: [server]
summary: Get the transcript
responses:
"200":
description: Transcript with word/segment timings
content:
application/json:
schema: { $ref: "#/components/schemas/Transcript" }
"404": { $ref: "#/components/responses/NotFound" }
"409": { description: Not transcribed yet }
/recordings/{id}/summary:
parameters:
- { in: path, name: id, required: true, schema: { type: string } }
get:
tags: [server]
summary: Get the LLM summary (overview, key points, action items)
responses:
"200":
description: Summary
content:
application/json:
schema: { $ref: "#/components/schemas/Summary" }
"404": { $ref: "#/components/responses/NotFound" }
"409": { description: Not summarised yet }
/recordings/{id}/export:
parameters:
- { in: path, name: id, required: true, schema: { type: string } }
- in: query
name: format
required: true
schema:
type: string
enum: [wav, ogg, txt, srt, vtt, md, json]
description: >
Export format. audio (wav/ogg), plain text (txt), subtitles (srt/vtt),
Markdown summary (md), or the full record as json.
get:
tags: [server]
summary: Export a recording in the requested format
responses:
"200":
description: The exported artefact
content:
application/octet-stream: { schema: { type: string, format: binary } }
"404": { $ref: "#/components/responses/NotFound" }
/ingest:
post:
tags: [server]
summary: Ingest a recording (direct upload; also used by the device uploader)
description: >
Accepts audio + metadata and queues transcription and summarisation. The device
normally uploads to object storage and notifies here; direct multipart upload is
also supported for the app or manual import.
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
properties:
metadata: { $ref: "#/components/schemas/Recording" }
audio: { type: string, format: binary }
responses:
"202":
description: Accepted for processing
content:
application/json:
schema: { $ref: "#/components/schemas/Recording" }
"401": { $ref: "#/components/responses/Unauthorized" }
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
description: >
Bearer token. On the device the token is set in config and stored in NVS; on the
server it is an API key. Read-only listing may be unauthenticated on a trusted LAN
if the owner enables that in config.
responses:
Unauthorized:
description: Missing or invalid token
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
NotFound:
description: No such recording
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
schemas:
SyncState:
type: string
enum: [local, uploaded, ingested, transcribed, summarised]
Recording:
type: object
required: [id, started_at, duration_s, sample_rate, channels, codec]
properties:
id: { type: string, example: "rec_20260703T101500Z_ab12" }
device_id: { type: string, example: "openscribe-abc123" }
started_at: { type: string, format: date-time }
duration_s: { type: number, example: 372.5 }
sample_rate: { type: integer, example: 16000 }
channels: { type: integer, example: 1 }
codec: { type: string, example: "wav_pcm_s16le" }
size_bytes: { type: integer, example: 11920000 }
sha256: { type: string }
source: { type: string, enum: [device, upload], default: device }
sync_state: { $ref: "#/components/schemas/SyncState" }
transcript_ref: { type: [string, "null"] }
summary_ref: { type: [string, "null"] }
RecordingPage:
type: object
properties:
items:
type: array
items: { $ref: "#/components/schemas/Recording" }
next_cursor: { type: [string, "null"] }
TranscriptSegment:
type: object
properties:
start: { type: number }
end: { type: number }
text: { type: string }
speaker: { type: [string, "null"], description: "Reserved for future diarisation" }
Transcript:
type: object
properties:
recording_id: { type: string }
language: { type: string, example: "en" }
model: { type: string, example: "faster-whisper:base" }
text: { type: string }
segments:
type: array
items: { $ref: "#/components/schemas/TranscriptSegment" }
Summary:
type: object
properties:
recording_id: { type: string }
model: { type: string, example: "ollama:llama3.1" }
overview: { type: string }
key_points:
type: array
items: { type: string }
action_items:
type: array
items: { type: string }
DeviceStatus:
type: object
properties:
device_id: { type: string }
firmware_version: { type: string }
state: { type: string, enum: [idle, recording, uploading] }
power: { type: string, enum: [battery, charging, powered] }
battery_pct: { type: [integer, "null"] }
storage_free_bytes: { type: integer }
storage_total_bytes: { type: integer }
recordings_count: { type: integer }
wifi_connected: { type: boolean }
ip: { type: [string, "null"] }
DeviceConfig:
type: object
properties:
device_id: { type: string }
wifi_ssid: { type: [string, "null"] }
upload_target: { type: string, enum: [none, s3, webdav], default: none }
upload_endpoint: { type: [string, "null"] }
upload_bucket_or_path: { type: [string, "null"] }
codec: { type: string, enum: [wav_pcm_s16le, adpcm, opus], default: wav_pcm_s16le }
sample_rate: { type: integer, default: 16000 }
# secrets (wifi_psk, s3 keys) are write-only via DeviceConfigUpdate and never returned
DeviceConfigUpdate:
allOf:
- $ref: "#/components/schemas/DeviceConfig"
- type: object
properties:
wifi_psk: { type: string, writeOnly: true }
upload_access_key: { type: string, writeOnly: true }
upload_secret_key: { type: string, writeOnly: true }
api_token: { type: string, writeOnly: true }
Error:
type: object
properties:
error: { type: string }
detail: { type: [string, "null"] }